diff --git a/docs/0.9.5/.buildinfo b/docs/0.9.5/.buildinfo
new file mode 100644
index 0000000000..a1dcffbaed
--- /dev/null
+++ b/docs/0.9.5/.buildinfo
@@ -0,0 +1,4 @@
+# Sphinx build info version 1
+# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
+config: 0ee6fdfcbf5c5e4fbbc12c88a038698e
+tags: 645f666f9bcd5a90fca523b33c5a78b7
diff --git a/docs/0.9.5/.nojekyll b/docs/0.9.5/.nojekyll
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/docs/0.9.5/A-voice-operated-elevator-using-events.html b/docs/0.9.5/A-voice-operated-elevator-using-events.html
new file mode 100644
index 0000000000..b30b5e4b7e
--- /dev/null
+++ b/docs/0.9.5/A-voice-operated-elevator-using-events.html
@@ -0,0 +1,541 @@
+
+
+
+
+
+
+
+
+ A voice operated elevator using events — Evennia 0.9.5 documentation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This tutorial will walk you through the steps to create a voice-operated elevator, using the in-
+game Python
+system.
+This tutorial assumes the in-game Python system is installed in your game. If it isn’t, you can
+follow the installation steps given in the documentation on in-game
+Python, and
+come back on this tutorial once the system is installed. You do not need to read the entire
+documentation, it’s a good reference, but not the easiest way to learn about it. Hence these
+tutorials.
+
The in-game Python system allows to run code on individual objects in some situations. You don’t
+have to modify the source code to add these features, past the installation. The entire system
+makes it easy to add specific features to some objects, but not all.
+
+
What will we try to do?
+
+
In this tutorial, we are going to create a simple voice-operated elevator. In terms of features, we
+will:
Let’s summarize what we want to achieve first. We would like to create a room that will represent
+the inside of our elevator. In this room, a character could just say “1”, “2” or “3”, and the
+elevator will start moving. The doors will close and open on the new floor (the exits leading in
+and out of the elevator will be modified).
+
We will work on basic features first, and then will adjust some, showing you how easy and powerfully
+independent actions can be configured through the in-game Python system.
We’ll create an elevator right in our room (generally called “Limbo”, of ID 2). You could easily
+adapt the following instructions if you already have some rooms and exits, of course, just remember
+to check the IDs.
+
+
Note: the in-game Python system uses IDs for a lot of things. While it is not mandatory, it is
+good practice to know the IDs you have for your callbacks, because it will make manipulation much
+quicker. There are other ways to identify objects, but as they depend on many factors, IDs are
+usually the safest path in our callbacks.
+
+
Let’s go into limbo (#2) to add our elevator. We’ll add it to the north. To create this room,
+in-game you could type:
+
tunnel n = Inside of an elevator
+
+
+
The game should respond by telling you:
+
Created room Inside of an elevator(#3) of type typeclasses.rooms.Room.
+Created Exit from Limbo to Inside of an elevator: north(#4) (n).
+Created Exit back from Inside of an elevator to Limbo: south(#5) (s).
+
+
+
Note the given IDs:
+
+
#2 is limbo, the first room the system created.
+
#3 is our room inside of an elevator.
+
#4 is the north exit from Limbo to our elevator.
+
#5 is the south exit from an elevator to Limbo.
+
+
Keep these IDs somewhere for the demonstration. You will shortly see why they are important.
+
+
Why have we created exits to our elevator and back to Limbo? Isn’t the elevator supposed to move?
+
+
It is. But we need to have exits that will represent the way inside the elevator and out. What we
+will do, at every floor, will be to change these exits so they become connected to the right room.
+You’ll see this process a bit later.
+
We have two more rooms to create: our floor 2 and 3. This time, we’ll use dig, because we don’t
+need exits leading there, not yet anyway.
+
dig The second floor
+dig The third floor
+
+
+
Evennia should answer with:
+
Created room The second floor(#6) of type typeclasses.rooms.Room.
+Created room The third floor(#7) of type typeclasses.rooms.Room.
+
Let’s go to the elevator (you could use tel#3 if you have the same IDs I have).
+
This is our elevator room. It looks a bit empty, feel free to add a prettier description or other
+things to decorate it a bit.
+
But what we want now is to be able to say “1”, “2” or “3” and have the elevator move in that
+direction.
+
If you have read the previous tutorial about adding dialogues in events, you
+may remember what we need to do. If not, here’s a summary: we need to run some code when somebody
+speaks in the room. So we need to create a callback (the callback will contain our lines of code).
+We just need to know on which event this should be set. You can enter callhere to see the
+possible events in this room.
+
In the table, you should see the “say” event, which is called when somebody says something in the
+room. So we’ll need to add a callback to this event. Don’t worry if you’re a bit lost, just follow
+the following steps, the way they connect together will become more obvious.
+
call/add here = say 1, 2, 3
+
+
+
+
We need to add a callback. A callback contains the code that will be executed at a given time.
+So we use the call/add command and switch.
+
here is our object, the room in which we are.
+
An equal sign.
+
The name of the event to which the callback should be connected. Here, the event is “say”.
+Meaning this callback will be executed every time somebody says something in the room.
+
But we add an event parameter to indicate the keywords said in the room that should execute our
+callback. Otherwise, our callback would be called every time somebody speaks, no matter what. Here
+we limit, indicating our callback should be executed only if the spoken message contains “1”, “2” or
+“3”.
+
+
An editor should open, inviting you to enter the Python code that should be executed. The first
+thing to remember is to read the text provided (it can contain important information) and, most of
+all, the list of variables that are available in this callback:
This is important, in order to know what variables we can use in our callback out-of-the-box. Let’s
+write a single line to be sure our callback is called when we expect it to:
+
character.msg("You just said {}.".format(message))
+
+
+
You can paste this line in-game, then type the :wq command to exit the editor and save your
+modifications.
+
Let’s check. Try to say “hello” in the room. You should see the standard message, but nothing
+more. Now try to say “1”. Below the standard message, you should see:
+
You just said 1.
+
+
+
You can try it. Our callback is only called when we say “1”, “2” or “3”. Which is just what we
+want.
+
Let’s go back in our code editor and add something more useful.
+
call/edit here = say
+
+
+
+
Notice that we used the “edit” switch this time, since the callback exists, we just want to edit
+it.
+
+
The editor opens again. Let’s empty it first:
+
:DD
+
+
+
And turn off automatic indentation, which will help us:
+
:=
+
+
+
+
Auto-indentation is an interesting feature of the code editor, but we’d better not use it at this
+point, it will make copy/pasting more complicated.
So here’s the time to truly code our callback in-game. Here’s a little reminder:
+
+
We have all the IDs of our three rooms and two exits.
+
When we say “1”, “2” or “3”, the elevator should move to the right room, that is change the
+exits. Remember, we already have the exits, we just need to change their location and destination.
+
+
It’s a good idea to try to write this callback yourself, but don’t feel bad about checking the
+solution right now. Here’s a possible code that you could paste in the code editor:
+
# First let's have some constants
+ELEVATOR=get(id=3)
+FLOORS={
+ "1":get(id=2),
+ "2":get(id=6),
+ "3":get(id=7),
+}
+TO_EXIT=get(id=4)
+BACK_EXIT=get(id=5)
+
+# Now we check that the elevator isn't already at this floor
+floor=FLOORS.get(message)
+iffloorisNone:
+ character.msg("Which floor do you want?")
+elifTO_EXIT.locationisfloor:
+ character.msg("The elevator already is at this floor.")
+else:
+ # 'floor' contains the new room where the elevator should be
+ room.msg_contents("The doors of the elevator close with a clank.")
+ TO_EXIT.location=floor
+ BACK_EXIT.destination=floor
+ room.msg_contents("The doors of the elevator open to {floor}.",
+ mapping=dict(floor=floor))
+
+
+
Let’s review this longer callback:
+
+
We first obtain the objects of both exits and our three floors. We use the get() eventfunc,
+which is a shortcut to obtaining objects. We usually use it to retrieve specific objects with an
+ID. We put the floors in a dictionary. The keys of the dictionary are the floor number (as str),
+the values are room objects.
+
Remember, the message variable contains the message spoken in the room. So either “1”, “2”, or
+“3”. We still need to check it, however, because if the character says something like “1 2” in the
+room, our callback will be executed. Let’s be sure what she says is a floor number.
+
We then check if the elevator is already at this floor. Notice that we use TO_EXIT.location.
+TO_EXIT contains our “north” exit, leading inside of our elevator. Therefore, its location will
+be the room where the elevator currently is.
+
If the floor is a different one, have the elevator “move”, changing just the location and
+destination of both exits.
+
+
The BACK_EXIT (that is “north”) should change its location. The elevator shouldn’t be
+accessible through our old floor.
+
The TO_EXIT (that is “south”, the exit leading out of the elevator) should have a different
+destination. When we go out of the elevator, we should find ourselves in the new floor, not the old
+one.
+
+
+
+
Feel free to expand on this example, changing messages, making further checks. Usage and practice
+are keys.
+
You can quit the editor as usual with :wq and test it out.
Let’s improve our callback. One thing that’s worth adding would be a pause: for the time being,
+when we say the floor number in the elevator, the doors close and open right away. It would be
+better to have a pause of several seconds. More logical.
+
This is a great opportunity to learn about chained events. Chained events are very useful to create
+pauses. Contrary to the events we have seen so far, chained events aren’t called automatically.
+They must be called by you, and can be called after some time.
+
+
Chained events always have the name “chain_X”. Usually, X is a number, but you can give the
+chained event a more explicit name.
+
In our original callback, we will call our chained events in, say, 15 seconds.
+
We’ll also have to make sure the elevator isn’t already moving.
+
+
Other than that, a chained event can be connected to a callback as usual. We’ll create a chained
+event in our elevator, that will only contain the code necessary to open the doors to the new floor.
+
call/add here = chain_1
+
+
+
The callback is added to the “chain_1” event, an event that will not be automatically called by the
+system when something happens. Inside this event, you can paste the code to open the doors at the
+new floor. You can notice a few differences:
+
TO_EXIT.location=floor
+TO_EXIT.destination=ELEVATOR
+BACK_EXIT.location=ELEVATOR
+BACK_EXIT.destination=floor
+room.msg_contents("The doors of the elevator open to {floor}.",
+ mapping=dict(floor=floor))
+
+
+
Paste this code into the editor, then use :wq to save and quit the editor.
+
Now let’s edit our callback in the “say” event. We’ll have to change it a bit:
+
+
The callback will have to check the elevator isn’t already moving.
+
It must change the exits when the elevator move.
+
It has to call the “chain_1” event we have defined. It should call it 15 seconds later.
+
+
Let’s see the code in our callback.
+
call/edit here = say
+
+
+
Remove the current code and disable auto-indentation again:
+
:DD
+:=
+
+
+
And you can paste instead the following code. Notice the differences with our first attempt:
+
# First let's have some constants
+ELEVATOR=get(id=3)
+FLOORS={
+ "1":get(id=2),
+ "2":get(id=6),
+ "3":get(id=7),
+}
+TO_EXIT=get(id=4)
+BACK_EXIT=get(id=5)
+
+# Now we check that the elevator isn't already at this floor
+floor=FLOORS.get(message)
+iffloorisNone:
+ character.msg("Which floor do you want?")
+elifBACK_EXIT.locationisNone:
+ character.msg("The elevator is between floors.")
+elifTO_EXIT.locationisfloor:
+ character.msg("The elevator already is at this floor.")
+else:
+ # 'floor' contains the new room where the elevator should be
+ room.msg_contents("The doors of the elevator close with a clank.")
+ TO_EXIT.location=None
+ BACK_EXIT.location=None
+ call_event(room,"chain_1",15)
+
+
+
What changed?
+
+
We added a little test to make sure the elevator wasn’t already moving. If it is, the
+BACK_EXIT.location (the “south” exit leading out of the elevator) should be None. We’ll remove
+the exit while the elevator is moving.
+
When the doors close, we set both exits’ location to None. Which “removes” them from their
+room but doesn’t destroy them. The exits still exist but they don’t connect anything. If you say
+“2” in the elevator and look around while the elevator is moving, you won’t see any exits.
+
Instead of opening the doors immediately, we call call_event. We give it the object containing
+the event to be called (here, our elevator), the name of the event to be called (here, “chain_1”)
+and the number of seconds from now when the event should be called (here, 15).
+
The chain_1 callback we have created contains the code to “re-open” the elevator doors. That
+is, besides displaying a message, it reset the exits’ location and destination.
+
+
If you try to say “3” in the elevator, you should see the doors closing. Look around you and you
+won’t see any exit. Then, 15 seconds later, the doors should open, and you can leave the elevator
+to go to the third floor. While the elevator is moving, the exit leading to it will be
+inaccessible.
+
+
Note: we don’t define the variables again in our chained event, we just call them. When we
+execute call_event, a copy of our current variables is placed in the database. These variables
+will be restored and accessible again when the chained event is called.
+
+
You can use the call/tasks command to see the tasks waiting to be executed. For instance, say “2”
+in the room, notice the doors closing, and then type the call/tasks command. You will see a task
+in the elevator, waiting to call the chain_1 event.
Here’s another nice little feature of events: you can modify the message of a single exit without
+altering the others. In this case, when someone goes north into our elevator, we’d like to see
+something like: “someone walks into the elevator.” Something similar for the back exit would be
+great too.
+
Inside of the elevator, you can look at the available events on the exit leading outside (south).
+
call south
+
+
+
You should see two interesting rows in this table:
So we can change the message others see when a character leaves, by editing the “msg_leave” event.
+Let’s do that:
+
call/add south = msg_leave
+
+
+
Take the time to read the help. It gives you all the information you should need. We’ll need to
+change the “message” variable, and use custom mapping (between braces) to alter the message. We’re
+given an example, let’s use it. In the code editor, you can paste the following line:
+
message="{character} walks out of the elevator."
+
+
+
Again, save and quit the editor by entering :wq. You can create a new character to see it leave.
This is a crude way to force our beggar out of the elevator, but it allows us to test. You should
+see:
+
A beggar(#8) walks out of the elevator.
+
+
+
Great! Let’s do the same thing for the exit leading inside of the elevator. Follow the beggar,
+then edit “msg_leave” of “north”:
+
call/add north = msg_leave
+
+
+
message="{character} walks into the elevator."
+
+
+
Again, you can force our beggar to move and see the message we have just set. This modification
+applies to these two exits, obviously: the custom message won’t be used for other exits. Since we
+use the same exits for every floor, this will be available no matter at what floor the elevator is,
+which is pretty neat!
Q: what happens if the game reloads or shuts down while a task is waiting to happen?
+
A: if your game reloads while a task is in pause (like our elevator between floors), when the
+game is accessible again, the task will be called (if necessary, with a new time difference to take
+into account the reload). If the server shuts down, obviously, the task will not be called, but
+will be stored and executed when the server is up again.
+
Q: can I use all kinds of variables in my callback? Whether chained or not?
+
A: you can use every variable type you like in your original callback. However, if you
+execute call_event, since your variables are stored in the database, they will need to respect the
+constraints on persistent attributes. A callback will not be stored in this way, for instance.
+This variable will not be available in your chained event.
+
Q: when you say I can call my chained events something else than “chain_1”, “chain_2” and
+such, what is the naming convention?
+
A: chained events have names beginning by “chain_”. This is useful for you and for the
+system. But after the underscore, you can give a more useful name, like “chain_open_doors” in our
+case.
+
Q: do I have to pause several seconds to call a chained event?
+
A: no, you can call it right away. Just leave the third parameter of call_event out (it
+will default to 0, meaning the chained event will be called right away). This will not create a
+task.
+
Q: can I have chained events calling themselves?
+
A: you can. There’s no limitation. Just be careful, a callback that calls itself,
+particularly without delay, might be a good recipe for an infinite loop. However, in some cases, it
+is useful to have chained events calling themselves, to do the same repeated action every X seconds
+for instance.
+
Q: what if I need several elevators, do I need to copy/paste these callbacks each time?
+
A: not advisable. There are definitely better ways to handle this situation. One of them is
+to consider adding the code in the source itself. Another possibility is to call chained events
+with the expected behavior, which makes porting code very easy. This side of chained events will be
+shown in the next tutorial.
Building up to Evennia 1.0 and beyond, it’s time to comb through the Evennia API for old cruft. This
+whitepage is for anyone interested to contribute with their views on what part of the API needs
+refactoring, cleanup or clarification (or extension!)
+
Note that this is not a forum. To keep things clean, each opinion text should ideally present a
+clear argument or lay out a suggestion. Asking for clarification and any side-discussions should be
+held in chat or forum.
This is how to enter an opinion. Use any markdown needed but stay within your section. Also remember
+to copy your text to the clipboard before saving since if someone else edited the wiki in the
+meantime you’ll have to start over.
I don’t agree with removing explicit keywords as suggested by [Johnny on Aug 29 below](API-
+refactoring#reduce-usage-of-optionalpositional-arguments-aug-29-2019). Overriding such a method can
+still be done by get(self,**kwargs) if so desired, making the kwargs explicit helps IMO
+readability of the API. If just giving a generic **kwargs, one must read the docstring or even the
+code to see which keywords are valid.
+
On the other hand, I think it makes sense to as a standard offer an extra **kwargs at the end of
+arg-lists for common methods that are expected to be over-ridden. This make the API more flexible by
+hinting to the dev that they could expand their own over-ridden implementation with their own
+keyword arguments if so desired.
Many classes have methods requiring lengthy positional argument lists, which are tedious and error-
+prone to extend and override especially in cases where not all arguments are even required. It would
+be useful if arguments were reserved for required inputs and anything else relegated to kwargs for
+easier passthrough on extension.
All users (real people) that starts a game Session on Evennia are doing so through an
+object called Account. The Account object has no in-game representation, it represents a unique
+game account. In order to actually get on the game the Account must puppet an Object
+(normally a Character).
+
Exactly how many Sessions can interact with an Account and its Puppets at once is determined by
+Evennia’s MULTISESSION_MODE setting.
+
Apart from storing login information and other account-specific data, the Account object is what is
+chatting on Channels. It is also a good place to store Permissions to be
+consistent between different in-game characters as well as configuration options. The Account
+object also has its own CmdSet, the AccountCmdSet.
+
Logged into default evennia, you can use the ooc command to leave your current
+character and go into OOC mode. You are quite limited in this mode, basically it works
+like a simple chat program. It acts as a staging area for switching between Characters (if your
+game supports that) or as a safety mode if your Character gets deleted. Use ic to attempt to
+(re)puppet a Character.
+
Note that the Account object can have, and often does have, a different set of
+Permissions from the Character they control. Normally you should put your
+permissions on the Account level - this will overrule permissions set on the Character level. For
+the permissions of the Character to come into play the default quell command can be used. This
+allows for exploring the game using a different permission set (but you can’t escalate your
+permissions this way - for hierarchical permissions like Builder, Admin etc, the lower of the
+permissions on the Character/Account will always be used).
You will usually not want more than one Account typeclass for all new accounts (but you could in
+principle create a system that changes an account’s typeclass dynamically).
+
An Evennia Account is, per definition, a Python class that includes evennia.DefaultAccount among
+its parents. In mygame/typeclasses/accounts.py there is an empty class ready for you to modify.
+Evennia defaults to using this (it inherits directly from DefaultAccount).
+
Here’s an example of modifying the default Account class in code:
+
# in mygame/typeclasses/accounts.py
+
+ fromevenniaimportDefaultAccount
+
+ classAccount(DefaultAccount):# [...]
+
+ at_account_creation(self):"this is called only once, when account is first created"
+ self.db.real_name=None# this is set later self.db.real_address = None #
+"
+ self.db.config_1=True# default config self.db.config_2 = False # "
+ self.db.config_3=1# "
+
+ # ... whatever else our game needs to know ``` Reload the server with `reload`.
+
+
+
+
… However, if you use examine*self (the asterisk makes you examine your Account object rather
+than your Character), you won’t see your new Attributes yet. This is because at_account_creation
+is only called the very first time the Account is called and your Account object already exists
+(any new Accounts that connect will see them though). To update yourself you need to make sure to
+re-fire the hook on all the Accounts you have already created. Here is an example of how to do this
+using py:
If you wanted Evennia to default to a completely different Account class located elsewhere, you
+must point Evennia to it. Add BASE_ACCOUNT_TYPECLASS to your settings file, and give the python
+path to your custom class as its value. By default this points to typeclasses.accounts.Account,
+the empty template we used above.
Beyond those properties assigned to all typeclassed objects (see Typeclasses), the
+Account also has the following custom properties:
+
+
user - a unique link to a User Django object, representing the logged-in user.
+
obj - an alias for character.
+
name - an alias for user.username
+
sessions - an instance of
+ObjectSessionHandler
+managing all connected Sessions (physical connections) this object listens to (Note: In older
+versions of Evennia, this was a list). The so-called session-id (used in many places) is found
+as
+a property sessid on each Session instance.
+
is_superuser (bool: True/False) - if this account is a superuser.
+
+
Special handlers:
+
+
cmdset - This holds all the current Commands of this Account. By default these are
+the commands found in the cmdset defined by settings.CMDSET_ACCOUNT.
+
nicks - This stores and handles Nicks, in the same way as nicks it works on Objects.
+For Accounts, nicks are primarily used to store custom aliases for
+Channels.
+
+
Selection of special methods (see evennia.DefaultAccount for details):
+
+
get_puppet - get a currently puppeted object connected to the Account and a given session id, if
+any.
+
puppet_object - connect a session to a puppetable Object.
+
unpuppet_object - disconnect a session from a puppetable Object.
+
msg - send text to the Account
+
execute_cmd - runs a command as if this Account did it.
Evennia leverages Django which is a web development framework.
+Huge professional websites are made in Django and there is extensive documentation (and books) on it
+. You are encouraged to at least look at the Django basic tutorials. Here we will just give a brief
+introduction for how things hang together, to get you started.
+
We assume you have installed and set up Evennia to run. A webserver and website comes out of the
+box. You can get to that by entering http://localhost:4001 in your web browser - you should see a
+welcome page with some game statistics and a link to the web client. Let us add a new page that you
+can get to by going to http://localhost:4001/story.
A django “view” is a normal Python function that django calls to render the HTML page you will see
+in the web browser. Here we will just have it spit back the raw html, but Django can do all sorts of
+cool stuff with the page in the view, like adding dynamic content or change it on the fly. Open
+mygame/web folder and add a new module there named story.py (you could also put it in its own
+folder if you wanted to be neat. Don’t forget to add an empty __init__.py file if you do, to tell
+Python you can import from the new folder). Here’s how it looks:
+
# in mygame/web/story.py
+
+fromdjango.shortcutsimportrender
+
+defstorypage(request):
+ returnrender(request,"story.html")
+
+
+
This view takes advantage of a shortcut provided to use by Django, render. This shortcut gives the
+template some information from the request, for instance, the game name, and then renders it.
We need to find a place where Evennia (and Django) looks for html files (called templates in
+Django parlance). You can specify such places in your settings (see the TEMPLATES variable in
+default_settings.py for more info), but here we’ll use an existing one. Go to
+mygame/template/overrides/website/ and create a page story.html there.
+
This is not a HTML tutorial, so we’ll go simple:
+
{% extends "base.html" %}
+{% block content %}
+<divclass="row">
+ <divclass="col">
+ <h1>A story about a tree</h1>
+ <p>
+ This is a story about a tree, a classic tale ...
+ </p>
+ </div>
+</div>
+{% endblock %}
+
+
+
Since we’ve used the render shortcut, Django will allow us to extend our base styles easily.
+
If you’d rather not take advantage of Evennia’s base styles, you can do something like this instead:
+
<html>
+ <body>
+ <h1>A story about a tree</h1>
+ <p>
+ This is a story about a tree, a classic tale ...
+ </body>
+</html>
+
When you enter the address http://localhost:4001/story in your web browser, Django will parse that
+field to figure out which page you want to go to. You tell it which patterns are relevant in the
+file
+mygame/web/urls.py.
+Open it now.
+
Django looks for the variable urlpatterns in this file. You want to add your new pattern to the
+custom_patterns list we have prepared - that is then merged with the default urlpatterns. Here’s
+how it could look:
That is, we import our story view module from where we created it earlier and then create an url
+instance. The first argument to url is the pattern of the url we want to find ("story") (this is
+a regular expression if you are familiar with those) and then our view function we want to direct
+to.
+
That should be it. Reload Evennia and you should be able to browse to your new story page!
Before doing this tutorial you will probably want to read the intro in
+Basic Web tutorial. Reading the three first parts of the
+Django tutorial might help as well.
+
This tutorial will provide a step-by-step process to installing a wiki on your website.
+Fortunately, you don’t have to create the features manually, since it has been done by others, and
+we can integrate their work quite easily with Django. I have decided to focus on
+the Django-wiki.
+
+
Note: this article has been updated for Evennia 0.9. If you’re not yet using this version, be
+careful, as the django wiki doesn’t support Python 2 anymore. (Remove this note when enough time
+has passed.)
+
+
The Django-wiki offers a lot of features associated with
+wikis, is
+actively maintained (at this time, anyway), and isn’t too difficult to install in Evennia. You can
+see a demonstration of Django-wiki here.
You should begin by shutting down the Evennia server if it is running. We will run migrations and
+alter the virtual environment just a bit. Open a terminal and activate your Python environment, the
+one you use to run the evennia command.
Note: this will install the last version of Django wiki. Version >0.4 doesn’t support Python 2, so
+install wiki 0.3 if you haven’t updated to Python 3 yet.
+
+
It might take some time, the Django-wiki having some dependencies.
You will need to add a few settings to have the wiki app on your website. Open your
+server/conf/settings.py file and add the following at the bottom (but before importing
+secret_settings). Here’s what you’ll find in my own setting file (add the whole Django-wiki
+section):
+
r"""
+Evennia settings file.
+
+...
+
+"""
+
+# Use the defaults from Evennia unless explicitly overridden
+fromevennia.settings_defaultimport*
+
+######################################################################
+# Evennia base server config
+######################################################################
+
+# This is the name of your game. Make it catchy!
+SERVERNAME="demowiki"
+
+######################################################################
+# Django-wiki settings
+######################################################################
+INSTALLED_APPS+=(
+ 'django.contrib.humanize.apps.HumanizeConfig',
+ 'django_nyt.apps.DjangoNytConfig',
+ 'mptt',
+ 'sorl.thumbnail',
+ 'wiki.apps.WikiConfig',
+ 'wiki.plugins.attachments.apps.AttachmentsConfig',
+ 'wiki.plugins.notifications.apps.NotificationsConfig',
+ 'wiki.plugins.images.apps.ImagesConfig',
+ 'wiki.plugins.macros.apps.MacrosConfig',
+)
+
+# Disable wiki handling of login/signup
+WIKI_ACCOUNT_HANDLING=False
+WIKI_ACCOUNT_SIGNUP_ALLOWED=False
+
+######################################################################
+# Settings given in secret_settings.py override those in this file.
+######################################################################
+try:
+ fromserver.conf.secret_settingsimport*
+exceptImportError:
+ print("secret_settings.py file not found or failed to import.")
+
Next we need to add two URLs in our web/urls.py file. Open it and compare the following output:
+you will need to add two URLs in custom_patterns and add one import line:
+
fromdjango.conf.urlsimporturl,include
+fromdjango.urlsimportpath# NEW!
+
+# default evenni a patterns
+fromevennia.web.urlsimporturlpatterns
+
+# eventual custom patterns
+custom_patterns=[
+ # url(r'/desired/url/', view, name='example'),
+ url('notifications/',include('django_nyt.urls')),# NEW!
+ url('wiki/',include('wiki.urls')),# NEW!
+]
+
+# this is required by Django.
+urlpatterns=custom_patterns+urlpatterns
+
+
+
You will probably need to copy line 2, 10, and 11. Be sure to place them correctly, as shown in
+the example above.
It’s time to run the new migrations. The wiki app adds a few tables in our database. We’ll need to
+run:
+
evennia migrate
+
+
+
And that’s it, you can start the server. If you go to http://localhost:4001/wiki , you should see
+the wiki. Use your account’s username and password to connect to it. That’s how simple it is.
A wiki can be a great collaborative tool, but who can see it? Who can modify it? Django-wiki comes
+with a privilege system centered around four values per wiki page. The owner of an article can
+always read and write in it (which is somewhat logical). The group of the article defines who can
+read and who can write, if the user seeing the page belongs to this group. The topic of groups in
+wiki pages will not be discussed here. A last setting determines which other user (that is, these
+who aren’t in the groups, and aren’t the article’s owner) can read and write. Each article has
+these four settings (group read, group write, other read, other write). Depending on your purpose,
+it might not be a good default choice, particularly if you have to remind every builder to keep the
+pages private. Fortunately, Django-wiki gives us additional settings to customize who can read, and
+who can write, a specific article.
+
These settings must be placed, as usual, in your server/conf/settings.py file. They take a
+function as argument, said function (or callback) will be called with the article and the user.
+Remember, a Django user, for us, is an account. So we could check lockstrings on them if needed.
+Here is a default setting to restrict the wiki: only builders can write in it, but anyone (including
+non-logged in users) can read it. The superuser has some additional privileges.
+
# In server/conf/settings.py
+# ...
+
+defis_superuser(article,user):
+ """Return True if user is a superuser, False otherwise."""
+ returnnotuser.is_anonymous()anduser.is_superuser
+
+defis_builder(article,user):
+ """Return True if user is a builder, False otherwise."""
+ returnnotuser.is_anonymous()anduser.locks.check_lockstring(user,"perm(Builders)")
+
+defis_anyone(article,user):
+ """Return True even if the user is anonymous."""
+ returnTrue
+
+# Who can create new groups and users from the wiki?
+WIKI_CAN_ADMIN=is_superuser
+# Who can change owner and group membership?
+WIKI_CAN_ASSIGN=is_superuser
+# Who can change group membership?
+WIKI_CAN_ASSIGN_OWNER=is_superuser
+# Who can change read/write access to groups or others?
+WIKI_CAN_CHANGE_PERMISSIONS=is_superuser
+# Who can soft-delete an article?
+WIKI_CAN_DELETE=is_builder
+# Who can lock an article and permanently delete it?
+WIKI_CAN_MODERATE=is_superuser
+# Who can edit articles?
+WIKI_CAN_WRITE=is_builder
+# Who can read articles?
+WIKI_CAN_READ=is_anyone
+
+
+
Here, we have created three functions: one to return True if the user is the superuser, one to
+return True if the user is a builder, one to return True no matter what (this includes if the
+user is anonymous, E.G. if it’s not logged-in). We then change settings to allow either the
+superuser or
+each builder to moderate, read, write, delete, and more. You can, of course, add more functions,
+adapting them to your need. This is just a demonstration.
+
Providing the WIKI_CAN*... settings will bypass the original permission system. The superuser
+could change permissions of an article, but still, only builders would be able to write it. If you
+need something more custom, you will have to expand on the functions you use.
Unfortunately, Django wiki doesn’t provide a clear and clean entry point to read and write articles
+from Evennia and it doesn’t seem to be a very high priority. If you really need to keep Django wiki
+and to create and manage wiki pages from your code, you can do so, but this article won’t elaborate,
+as this is somewhat more technical.
+
However, it is a good opportunity to present a small project that has been created more recently:
+evennia-wiki has been created to provide a simple
+wiki, more tailored to Evennia and easier to connect. It doesn’t, as yet, provide as many options
+as does Django wiki, but it’s perfectly usable:
+
+
Pages have an inherent and much-easier to understand hierarchy based on URLs.
+
Article permissions are connected to Evennia groups and are much easier to accommodate specific
+requirements.
+
Articles can easily be created, read or updated from the Evennia code itself.
+
Markdown is fully-supported with a default integration to Bootstrap to look good on an Evennia
+website. Tables and table of contents are supported as well as wiki links.
+
The process to override wiki templates makes full use of the template_overrides directory.
+
+
However evennia-wiki doesn’t yet support:
+
+
Images in markdown and the uploading schema. If images are important to you, please consider
+contributing to this new project.
+
Modifying permissions on a per page/setting basis.
+
Moving pages to new locations.
+
Viewing page history.
+
+
Considering the list of features in Django wiki, obviously other things could be added to the list.
+However, these features may be the most important and useful. Additional ones might not be that
+necessary. If you’re interested in supporting this little project, you are more than welcome to
+contribute to it. Thanks!
This is a quick first-time tutorial expanding on the Commands documentation.
+
Let’s assume you have just downloaded Evennia, installed it and created your game folder (let’s call
+it just mygame here). Now you want to try to add a new command. This is the fastest way to do it.
Open mygame/commands/command.py in a text editor. This is just one place commands could be
+placed but you get it setup from the onset as an easy place to start. It also already contains some
+example code.
+
Create a new class in command.py inheriting from default_cmds.MuxCommand. Let’s call it
+CmdEcho in this example.
+
Set the class variable key to a good command name, like echo.
+
Give your class a useful docstring. A docstring is the string at the very top of a class or
+function/method. The docstring at the top of the command class is read by Evennia to become the help
+entry for the Command (see
+Command Auto-help).
+
Define a class method func(self) that echoes your input back to you.
+
+
Below is an example how this all could look for the echo command:
+
# file mygame/commands/command.py
+ #[...]
+ fromevenniaimportdefault_cmds
+ classCmdEcho(default_cmds.MuxCommand):
+ """
+ Simple command example
+
+ Usage:
+ echo [text]
+
+ This command simply echoes text back to the caller.
+ """
+
+ key="echo"
+
+ deffunc(self):
+ "This actually does things"
+ ifnotself.args:
+ self.caller.msg("You didn't enter anything!")
+ else:
+ self.caller.msg("You gave the string: '%s'"%self.args)
+
The command is not available to use until it is part of a Command Set. In this
+example we will go the easiest route and add it to the default Character commandset that already
+exists.
+
+
Edit mygame/commands/default_cmdsets.py
+
Import your new command with fromcommands.commandimportCmdEcho.
+
Add a line self.add(CmdEcho()) to CharacterCmdSet, in the at_cmdset_creation method (the
+template tells you where).
+
+
This is approximately how it should look at this point:
+
# file mygame/commands/default_cmdsets.py
+ #[...]
+ fromcommands.commandimportCmdEcho
+ #[...]
+ classCharacterCmdSet(default_cmds.CharacterCmdSet):
+
+ key="DefaultCharacter"
+
+ defat_cmdset_creation(self):
+
+ # this first adds all default commands
+ super().at_cmdset_creation()
+
+ # all commands added after this point will extend or
+ # overwrite the default commands.
+ self.add(CmdEcho())
+
+
+
Next, run the @reload command. You should now be able to use your new echo command from inside
+the game. Use helpecho to see the documentation for the command.
+
If you have trouble, make sure to check the log for error messages (probably due to syntax errors in
+your command definition).
+
+
Note: Typing echotest will also work. It will be handled as the command echo directly followed
+by
+its argument test (which will end up in self.args).Tochangethisbehavior,youcanaddthearg_regexpropertyalongsidekey,help_category` etc. See the arg_regex
+documentation for more info.
+
+
If you want to overload existing default commands (such as look or get), just add your new
+command with the same key as the old one - it will then replace it. Just remember that you must use
+@reload to see any changes.
+
See Commands for many more details and possibilities when defining Commands and using
+Cmdsets in various ways.
Adding your Command to the CharacterCmdSet is just one easy exapmple. The cmdset system is very
+generic. You can create your own cmdsets (let’s say in a module mycmdsets.py) and add them to
+objects as you please (how to control their merging is described in detail in the Command Set
+documentation).
Now you just need to add this to an object. To test things (as superuser) you can do
+
@py self.cmdset.add("mycmdsets.MyCmdSet")
+
+
+
This will add this cmdset (along with its echo command) to yourself so you can test it. Note that
+you cannot add a single Command to an object on its own, it must be part of a CommandSet in order to
+do so.
+
The Command you added is not there permanently at this point. If you do a @reload the merger will
+be gone. You could add the permanent=True keyword to the cmdset.add call. This will however
+only make the new merged cmdset permanent on that single object. Often you want all objects of
+this particular class to have this cmdset.
+
To make sure all new created objects get your new merged set, put the cmdset.add call in your
+custom Typeclasses’ at_object_creation method:
+
# e.g. in mygame/typeclasses/objects.py
+
+ fromevenniaimportDefaultObject
+ classMyObject(DefaultObject):
+
+ defat_object_creation(self):
+ "called when the object is first created"
+ self.cmdset.add("mycmdset.MyCmdSet",permanent=True)
+
+
+
All new objects of this typeclass will now start with this cmdset and it will survive a @reload.
+
Note: An important caveat with this is that at_object_creation is only called once, when the
+object is first created. This means that if you already have existing objects in your databases
+using that typeclass, they will not have been initiated the same way. There are many ways to update
+them; since it’s a one-time update you can usually just simply loop through them. As superuser, try
+the following:
+
@py from typeclasses.objects import MyObject; [o.cmdset.add("mycmdset.MyCmdSet") for o in
+
+
+
MyObject.objects.all()]
+
This goes through all objects in your database having the right typeclass, adding the new cmdset to
+each. The good news is that you only have to do this if you want to post-add cmdsets. If you just
+want to add a new command, you can simply add that command to the cmdset’s at_cmdset_creation
+and @reload to make the Command immediately available.
Evennia uses settings variables to know where to look for its default command sets. These are
+normally not changed unless you want to re-organize your game folder in some way. For example, the
+default character cmdset defaults to being defined as
When you create a new Evennia game (with for example evennia--initmygame) Evennia will
+automatically create empty child classes Object, Character, Room and Exit respectively. They
+are found mygame/typeclasses/objects.py, mygame/typeclasses/rooms.py etc.
+
+
Technically these are all Typeclassed, which can be ignored for now. In
+mygame/typeclasses are also base typeclasses for out-of-character things, notably
+Channels, Accounts and Scripts. We don’t cover those in
+this tutorial.
+
+
For your own game you will most likely want to expand on these very simple beginnings. It’s normal
+to want your Characters to have various attributes, for example. Maybe Rooms should hold extra
+information or even all Objects in your game should have properties not included in basic Evennia.
The default build commands of a new Evennia game is set up to use the Room, Exit and Character
+classes found in the same-named modules under mygame/typeclasses/. By default these are empty and
+just implements the default parents from the Evennia library (DefaultRoometc). Just add the
+changes you want to these classes and run @reload to add your new functionality.
Say you want to create a new “Heavy” object-type that characters should not have the ability to pick
+up.
+
+
Edit mygame/typeclasses/objects.py (you could also create a new module there, named something
+like heavy.py, that’s up to how you want to organize things).
+
Create a new class inheriting at any distance from DefaultObject. It could look something like
+this:
+
+
# end of file mygame/typeclasses/objects.py
+ fromevenniaimportDefaultObject
+
+ classHeavy(DefaultObject):
+ "Heavy object"
+ defat_object_creation(self):
+ "Called whenever a new object is created"
+ # lock the object down by default
+ self.locks.add("get:false()")
+ # the default "get" command looks for this Attribute in order
+ # to return a customized error message (we just happen to know
+ # this, you'd have to look at the code of the 'get' command to
+ # find out).
+ self.db.get_err_msg="This is too heavy to pick up."
+
+
+
+
Once you are done, log into the game with a build-capable account and do @create/droprock:objects.Heavy to drop a new heavy “rock” object in your location. Next try to pick it up
+(@quell yourself first if you are a superuser). If you get errors, look at your log files where
+you will find the traceback. The most common error is that you have some sort of syntax error in
+your class.
+
+
Note that the Locks and Attribute which are set in the typeclass could just
+as well have been set using commands in-game, so this is a very simple example.
The at_object_creation is only called once, when the object is first created. This makes it ideal
+for database-bound things like Attributes. But sometimes you want to create temporary
+properties (things that are not to be stored in the database but still always exist every time the
+object is created). Such properties can be initialized in the at_init method on the object.
+at_init is called every time the object is loaded into memory.
+
+
Note: It’s usually pointless and wasteful to assign database data in at_init, since this will
+hit the database with the same value over and over. Put those in at_object_creation instead.
+
+
You are wise to use ndb (non-database Attributes) to store these non-persistent properties, since
+ndb-properties are protected against being cached out in various ways and also allows you to list
+them using various in-game tools:
Note: As mentioned in the Typeclasses documentation, at_init replaces the use of
+the standard __init__ method of typeclasses due to how the latter may be called in situations
+other than you’d expect. So use at_init where you would normally use __init__.
If you already have some Heavy objects created and you add a new Attribute in
+at_object_creation, you will find that those existing objects will not have this Attribute. This
+is not so strange, since at_object_creation is only called once, it will not be called again just
+because you update it. You need to update existing objects manually.
+
If the number of objects is limited, you can use @typeclass/force/reloadobjectname to force a
+re-load of the at_object_creation method (only) on the object. This case is common enough that
+there is an alias @updateobjectname you can use to get the same effect. If there are multiple
+objects you can use @py to loop over the objects you need:
Warning: This information is presented as a convenience, using another webserver than Evennia’s
+own is not directly supported and you are on your own if you want to do so. Evennia’s webserver
+works out of the box without any extra configuration and also runs in-process making sure to avoid
+caching race conditions. The browser web client will most likely not work (at least not without
+tweaking) on a third-party web server.
+
One reason for wanting to use an external webserver like Apache would be to act as a proxy in
+front of the Evennia webserver. Getting this working with TLS (encryption) requires some extra work
+covered at the end of this page.
+
Note that the Apache instructions below might be outdated. If something is not working right, or you
+use Evennia with a different server, please let us know. Also, if there is a particular Linux distro
+you would like covered, please let us know.
After mod_wsgi is installed, copy the evennia/web/utils/evennia_wsgi_apache.conf file to your
+apache2 vhosts/sites folder. On Debian/Ubuntu, this is /etc/apache2/sites-enabled/. Make your
+modifications after copying the file there.
+
Read the comments and change the paths to point to the appropriate locations within your setup.
With any luck, you’ll be able to point your browser at your domain or subdomain that you set up in
+your vhost and see the nifty default Evennia webpage. If not, read the hopefully informative error
+message and work from there. Questions may be directed to our Evennia Community
+site.
If your mod_wsgi is set up to run on daemon mode (as will be the case by default on Debian and
+Ubuntu), you may tell mod_wsgi to reload by using the touch command on
+evennia/game/web/utils/apache_wsgi.conf. When mod_wsgi sees that the file modification time has
+changed, it will force a code reload. Any modifications to the code will not be propagated to the
+live instance of your site until reloaded.
+
If you are not running in daemon mode or want to force the issue, simply restart or reload apache2
+to apply your changes.
If you get strange (and usually uninformative) Permissiondenied errors from Apache, make sure
+that your evennia directory is located in a place the webserver may actually access. For example,
+some Linux distributions may default to very restrictive access permissions on a user’s /home
+directory.
+
One user commented that they had to add the following to their Apache config to get things to work.
+Not confirmed, but worth trying if there are trouble.
+
<Directory "/home/<yourname>/evennia/game/web">
+ Options +ExecCGI
+ Allow from all
+</Directory>
+
Below are steps on running Evennia using a front-end proxy (Apache HTTP), mod_proxy_http,
+mod_proxy_wstunnel, and mod_ssl. mod_proxy_http and mod_proxy_wstunnel will simply be
+referred to as
+mod_proxy below.
Ubuntu/Debian - Apache HTTP Server and mod_ssljkl are installed together in the apache2
+package and available in the
+standard package repositories for Ubuntu and Debian. mod_ssl needs to be enabled after
+installation:
There is a slight trick in setting up Evennia so websocket traffic is handled correctly by the
+proxy. You must set the WEBSOCKET_CLIENT_URL setting in your mymud/server/conf/settings.py file:
The setting above is what the client’s browser will actually use. Note the use of wss:// is
+because our client will be communicating over an encrypted connection (“wss” indicates websocket
+over SSL/TLS). Also, especially note the additional path /ws at the end of the URL. This is how
+Apache HTTP Server identifies that a particular request should be proxied to Evennia’s websocket
+port but this should be applicable also to other types of proxies (like nginx).
Arx - After the Reckoning is a big and very popular
+Evennia-based game. Arx is heavily roleplaying-centric, relying on game
+masters to drive the story. Technically it’s maybe best described as “a MUSH, but with more coded
+systems”. In August of 2018, the game’s developer, Tehom, generously released the source code of
+Arx on github. This is a treasure-trove for developers wanting
+to pick ideas or even get a starting game to build on. These instructions are based on the Arx-code
+released as of Aug 12, 2018.
+
If you are not familiar with what Evennia is, you can read
+an introduction here.
+
It’s not too hard to run Arx from the sources (of course you’ll start with an empty database) but
+since part of Arx has grown organically, it doesn’t follow standard Evennia paradigms everywhere.
+This page covers one take on installing and setting things up while making your new Arx-based game
+better match with the vanilla Evennia install.
Firstly, set aside a folder/directory on your drive for everything to follow.
+
You need to start by installing Evennia by following most of the Getting
+Started
+Instructions for your OS. The difference is that you need to gitclonehttps://github.com/TehomCD/evennia.git instead of Evennia’s repo because Arx uses TehomCD’s older
+Evennia 0.8 fork, notably still using Python2. This detail is
+important if referring to newer Evennia documentation.
+
If you are new to Evennia it’s highly recommended that you run through the
+instructions in full - including initializing and starting a new empty game and connecting to it.
+That way you can be sure Evennia works correctly as a base line. If you have trouble, make sure to
+read the Troubleshooting instructions for your
+operating system. You can also drop into our
+forums, join #evennia on irc.freenode.net
+or chat from the linked Discord Server.
+
After installing you should have a virtualenv running and you should have the following file
+structure in your set-aside folder:
+
vienv/
+evennia/
+mygame/
+
+
+
+
Here mygame is the empty game you created during the Evennia install, with evennia--init. Go to
+that and run evenniastop to make sure your empty game is not running. We’ll instead let Evenna
+run Arx, so in principle you could erase mygame - but it could also be good to have a clean game
+to compare to.
Arx has split evennia’s normal settings into base_settings.py and production_settings.py. It
+also has its own solution for managing ‘secret’ parts of the settings file. We’ll keep most of Arx
+way but remove the secret-handling and replace it with the normal Evennia method.
+
Cd into myarx/server/conf/ and open the file settings.py in a text editor. The top part (within
+"""...""") is just help text. Wipe everything underneath that and make it look like this instead
+(don’t forget to save):
+
frombase_settingsimport*
+
+TELNET_PORTS=[4000]
+SERVERNAME="MyArx"
+GAME_SLOGAN="The cool game"
+
+try:
+ fromserver.conf.secret_settingsimport*
+exceptImportError:
+ print("secret_settings.py file not found or failed to import.")
+
+
+
+
Note: Indents and capitalization matter in Python. Make indents 4 spaces (not tabs) for your own
+sanity. If you want a starter on Python in Evennia, [you can look here](Python-basic-
+introduction).
+
+
This will import Arx’ base settings and override them with the Evennia-default telnet port and give
+the game a name. The slogan changes the sub-text shown under the name of your game in the website
+header. You can tweak these to your own liking later.
+
Next, create a new, empty file secret_settings.py in the same location as the settings.py file.
+This can just contain the following:
Replace the long random string with random ASCII characters of your own. The secret key should not
+be shared.
+
Next, open myarx/server/conf/base_settings.py in your text editor. We want to remove/comment out
+all mentions of the decouple package, which Evennia doesn’t use (we use private_settings.py to
+hide away settings that should not be shared).
+
Comment out fromdecoupleimportconfig by adding a # to the start of the line: #fromdecoupleimportconfig. Then search for config( in the file and comment out all lines where this is used.
+Many of these are specific to the server environment where the original Arx runs, so is not that
+relevant to us.
Arx has some further dependencies beyond vanilla Evennia. Start by cd:ing to the root of your
+myarx folder.
+
+
If you run Linux or Mac: Edit myarx/requirements.txt and comment out the line
+pypiwin32==219 - it’s only needed on Windows and will give an error on other platforms.
+
+
Make sure your virtualenv is active, then run
+
pip install -r requirements.txt
+
+
+
The needed Python packages will be installed for you.
This creates the database and will step through all database migrations needed.
+
evennia start
+
+
+
If all goes well Evennia will now start up, running Arx! You can connect to it on localhost (or
+127.0.0.1 if your platform doesn’t alias localhost), port 4000 using a Telnet client.
+Alternatively, you can use your web browser to browse to http://localhost:4001 to see the game’s
+website and get to the web client.
+
When you log in you’ll get the standard Evennia greeting (since the database is empty), but you can
+try help to see that it’s indeed Arx that is running.
The first time you start Evennia after creating the database with the evenniamigrate step above,
+it should create a few starting objects for you - your superuser account, which it will prompt you
+to enter, a starting room (Limbo), and a character object for you. If for some reason this does not
+occur, you may have to follow the steps below. For the first time Superuser login you may have to
+run steps 7-8 and 10 to create and connect to your in-came Character.
+
+
Login to the game website with your Superuser account.
+
Press the Admin button to get into the (Django-) Admin Interface.
+
Navigate to the Accounts section.
+
Add a new Account named for the new staffer. Use a place holder password and dummy e-mail
+address.
+
Flag account as Staff and apply the Admin permission group (This assumes you have already set
+up an Admin Group in Django).
+
Add Tags named player and developer.
+
Log into the game using the web client (or a third-party telnet client) using your superuser
+account. Move to where you want the new staffer character to appear.
+
In the game client, run @create/drop<staffername>:typeclasses.characters.Character, where
+<staffername> is usually the same name you used for the Staffer account you created in the
+Admin earlier (if you are creating a Character for your superuser, use your superuser account
+name).
+This creates a new in-game Character and places it in your current location.
+
Have the new Admin player log into the game.
+
Have the new Admin puppet the character with @icStafferName.
+
Have the new Admin change their password - @password<oldpassword>=<newpassword>.
+
+
Now that you have a Character and an Account object, there’s a few additional things you may need to
+do in order for some commands to function properly. You can either execute these as in-game commands
+while @ic (controlling your character object).
Those steps will give you a ‘RosterEntry’, ‘PlayerOrNpc’, and ‘AssetOwner’ objects. RosterEntry
+explicitly connects a character and account object together, even while offline, and contains
+additional information about a character’s current presence in game (such as which ‘roster’ they’re
+in, if you choose to use an active roster of characters). PlayerOrNpc are more character extensions,
+as well as support for npcs with no in-game presence and just represented by a name which can be
+offscreen members of a character’s family. It also allows for membership in Organizations.
+AssetOwner holds information about a character or organization’s money and resources.
If for some reason you cannot use the Windows Subsystem for Linux (which would use instructions
+identical to the ones above), it’s possible to get Evennia running under Anaconda for Windows. The
+process is a little bit trickier.
Replace the SSH git clone links below with your own github forks.
+If you don’t plan to change Evennia at all, you can use the
+evennia/evennia.git repo instead of a forked one.
…and do the first run. You need winpty because Windows does not have a TTY/PTY
+by default, and so the Python console input commands (used for prompts on first
+run) will fail and you will end up in an unhappy place. Future runs, you should
+not need winpty.
+
winpty …/evennia/bin/windows/evennia.bat start
+
Once this is done, you should have your Evennia server running Arxcode up
+on localhost at port 4000, and the webserver at http://localhost:4001/
Most program code operates synchronously. This means that each statement in your code gets
+processed and finishes before the next can begin. This makes for easy-to-understand code. It is also
+a requirement in many cases - a subsequent piece of code often depend on something calculated or
+defined in a previous statement.
+
Consider this piece of code in a traditional Python program:
When run, this will print "beforecall...", after which the long_running_function gets to work
+for however long time. Only once that is done, the system prints "aftercall...". Easy and
+logical to follow. Most of Evennia work in this way and often it’s important that commands get
+executed in the same strict order they were coded.
+
Evennia, via Twisted, is a single-process multi-user server. In simple terms this means that it
+swiftly switches between dealing with player input so quickly that each player feels like they do
+things at the same time. This is a clever illusion however: If one user, say, runs a command
+containing that long_running_function, all other players are effectively forced to wait until it
+finishes.
+
Now, it should be said that on a modern computer system this is rarely an issue. Very few commands
+run so long that other users notice it. And as mentioned, most of the time you want to enforce
+all commands to occur in strict sequence.
+
When delays do become noticeable and you don’t care in which order the command actually completes,
+you can run it asynchronously. This makes use of the run_async() function in
+src/utils/utils.py:
+
run_async(function,*args,**kwargs)
+
+
+
Where function will be called asynchronously with *args and **kwargs. Example:
Now, when running this you will find that the program will not wait around for
+long_running_function to finish. In fact you will see "beforecall..." and "aftercall..."
+printed out right away. The long-running function will run in the background and you (and other
+users) can go on as normal.
A complication with using asynchronous calls is what to do with the result from that call. What if
+long_running_function returns a value that you need? It makes no real sense to put any lines of
+code after the call to try to deal with the result from long_running_function above - as we saw
+the "aftercall..." got printed long before long_running_function was finished, making that
+line quite pointless for processing any data from the function. Instead one has to use callbacks.
+
utils.run_async takes reserved kwargs that won’t be passed into the long-running function:
+
+
at_return(r) (the callback) is called when the asynchronous function (long_running_function
+above) finishes successfully. The argument r will then be the return value of that function (or
+None).
+
defat_return(r):
+ print(r)
+
+
+
+
at_return_kwargs - an optional dictionary that will be fed as keyword arguments to the
+at_return callback.
+
at_err(e) (the errback) is called if the asynchronous function fails and raises an exception.
+This exception is passed to the errback wrapped in a Failure object e. If you do not supply an
+errback of your own, Evennia will automatically add one that silently writes errors to the evennia
+log. An example of an errback is found below:
+
+
defat_err(e):
+ print("There was an error:",str(e))
+
+
+
+
at_err_kwargs - an optional dictionary that will be fed as keyword arguments to the at_err
+errback.
+
+
An example of making an asynchronous call from inside a Command definition:
+
fromevenniaimportutils,Command
+
+ classCmdAsync(Command):
+
+ key="asynccommand"
+
+ deffunc(self):
+
+ deflong_running_function():
+ #[... lots of time-consuming code ...]
+ returnfinal_value
+
+ defat_return_function(r):
+ self.caller.msg("The final value is %s"%r)
+
+ defat_err_function(e):
+ self.caller.msg("There was an error: %s"%e)
+
+ # do the async call, setting all callbacks
+ utils.run_async(long_running_function,at_return=at_return_function,
+at_err=at_err_function)
+
+
+
That’s it - from here on we can forget about long_running_function and go on with what else need
+to be done. Whenever it finishes, the at_return_function function will be called and the final
+value will
+pop up for us to see. If not we will see an error message.
The delay function is a much simpler sibling to run_async. It is in fact just a way to delay the
+execution of a command until a future time. This is equivalent to something like time.sleep()
+except delay is asynchronous while sleep would lock the entire server for the duration of the
+sleep.
+
fromevennia.utilsimportdelay
+
+ # [...]
+ # e.g. inside a Command, where `self.caller` is available
+ defcallback(obj):
+ obj.msg("Returning!")
+ delay(10,callback,self.caller)
+
+
+
This will delay the execution of the callback for 10 seconds. This function is explored much more in
+the Command Duration Tutorial.
+
You can also try the following snippet just see how it works:
As of Evennia 0.9, the @interactivedecorator
+is available. This makes any function or method possible to ‘pause’ and/or await player input
+in an interactive way.
+
fromevennia.utilsimportinteractive
+
+ @interactive
+ defmyfunc(caller):
+
+ whileTrue:
+ caller.msg("Getting ready to wait ...")
+ yield(5)
+ caller.msg("Now 5 seconds have passed.")
+
+ response=yield("Do you want to wait another 5 secs?")
+
+ ifresponse.lower()notin("yes","y"):
+ break
+
+
+
The @interactive decorator gives the function the ability to pause. The use
+of yield(seconds) will do just that - it will asynchronously pause for the
+number of seconds given before continuing. This is technically equivalent to
+using call_async with a callback that continues after 5 secs. But the code
+with @interactive is a little easier to follow.
+
Within the @interactive function, the response=yield("question") question
+allows you to ask the user for input. You can then process the input, just like
+you would if you used the Python input function. There is one caveat to this
+functionality though - it will only work if the function/method has an
+argument named exactly caller. This is because internally Evennia will look
+for the caller argument and treat that as the source of input.
+
All of this makes the @interactive decorator very useful. But it comes with a
+few caveats. Notably, decorating a function/method with @interactive turns it
+into a Python generator. The most
+common issue is that you cannot use return<value> from a generator (just an
+empty return works). To return a value from a function/method you have decorated
+with @interactive, you must instead use a special Twisted function
+twisted.internet.defer.returnValue. Evennia also makes this function
+conveniently available from evennia.utils:
+
fromevennia.utilsimportinteractive,returnValue
+
+ @interactive
+ defmyfunc():
+
+ # ...
+ result=10
+
+ # this must be used instead of `return result`
+ returnValue(result)
+
+
Overall, be careful with choosing when to use asynchronous calls. It is mainly useful for large
+administration operations that have no direct influence on the game world (imports and backup
+operations come to mind). Since there is no telling exactly when an asynchronous call actually ends,
+using them for in-game commands is to potentially invite confusion and inconsistencies (and very
+hard-to-reproduce bugs).
+
The very first synchronous example above is not really correct in the case of Twisted, which is
+inherently an asynchronous server. Notably you might find that you will not see the first beforecall... text being printed out right away. Instead all texts could end up being delayed until
+after the long-running process finishes. So all commands will retain their relative order as
+expected, but they may appear with delays or in groups.
Technically, run_async is just a very thin and simplified wrapper around a
+Twisted Deferred object; the
+wrapper sets
+up a default errback also if none is supplied. If you know what you are doing there is nothing
+stopping you from bypassing the utility function, building a more sophisticated callback chain after
+your own liking.
When performing actions in Evennia it is often important that you store data for later. If you write
+a menu system, you have to keep track of the current location in the menu tree so that the player
+can give correct subsequent commands. If you are writing a combat system, you might have a
+combattant’s next roll get easier dependent on if their opponent failed. Your characters will
+probably need to store roleplaying-attributes like strength and agility. And so on.
+
Typeclassed game entities (Accounts, Objects,
+Scripts and Channels) always have Attributes associated with them.
+Attributes are used to store any type of data ‘on’ such entities. This is different from storing
+data in properties already defined on entities (such as key or location) - these have very
+specific names and require very specific types of data (for example you couldn’t assign a python
+list to the key property no matter how hard you tried). Attributes come into play when you
+want to assign arbitrary data to arbitrary names.
+
Attributes are not secure by default and any player may be able to change them unless you
+prevent this behavior.
To save persistent data on a Typeclassed object you normally use the db (DataBase) operator. Let’s
+try to save some data to a Rose (an Object):
+
# saving
+ rose.db.has_thorns=True
+ # getting it back
+ is_ouch=rose.db.has_thorns
+
+
+
+
This looks like any normal Python assignment, but that db makes sure that an Attribute is
+created behind the scenes and is stored in the database. Your rose will continue to have thorns
+throughout the life of the server now, until you deliberately remove them.
+
To be sure to save non-persistently, i.e. to make sure NOT to create a database entry, you use
+ndb (NonDataBase). It works in the same way:
+
# saving
+ rose.ndb.has_thorns=True
+ # getting it back
+ is_ouch=rose.ndb.has_thorns
+
+
+
Technically, ndb has nothing to do with Attributes, despite how similar they look. No
+Attribute object is created behind the scenes when using ndb. In fact the database is not
+invoked at all since we are not interested in persistence. There is however an important reason to
+use ndb to store data rather than to just store variables direct on entities - ndb-stored data
+is tracked by the server and will not be purged in various cache-cleanup operations Evennia may do
+while it runs. Data stored on ndb (as well as db) will also be easily listed by example the
+@examine command.
+
You can also del properties on db and ndb as normal. This will for example delete an
+Attribute:
+
delrose.db.has_thorns
+
+
+
Both db and ndb defaults to offering an all property on themselves. This returns all
+associated attributes or non-persistent properties.
The .db and .ndb properties are very convenient but if you don’t know the name of the Attribute
+beforehand they cannot be used. Behind the scenes .db actually accesses the AttributeHandler
+which sits on typeclassed entities as the .attributes property. .ndb does the same for the
+.nattributes property.
+
The handlers have normal access methods that allow you to manage and retrieve Attributes and
+NAttributes:
+
+
has('attrname') - this checks if the object has an Attribute with this key. This is equivalent
+to doing obj.db.attrname.
+
get(...) - this retrieves the given Attribute. Normally the value property of the Attribute is
+returned, but the method takes keywords for returning the Attribute object itself. By supplying an
+accessing_object to the call one can also make sure to check permissions before modifying
+anything.
+
add(...) - this adds a new Attribute to the object. An optional lockstring can be
+supplied here to restrict future access and also the call itself may be checked against locks.
+
remove(...) - Remove the given Attribute. This can optionally be made to check for permission
+before performing the deletion. - clear(...) - removes all Attributes from object.
+
all(...) - returns all Attributes (of the given category) attached to this object.
+
+
See this section for more about locking down Attribute
+access and editing. The Nattribute offers no concept of access control.
+
Some examples:
+
importevennia
+ obj=evennia.search_object("MyObject")
+
+ obj.attributes.add("test","testvalue")
+ print(obj.db.test)# prints "testvalue"
+ print(obj.attributes.get("test"))# "
+ print(obj.attributes.all())# prints [<AttributeObject>]
+ obj.attributes.remove("test")
+
An Attribute object is stored in the database. It has the following properties:
+
+
key - the name of the Attribute. When doing e.g. obj.db.attrname=value, this property is set
+to attrname.
+
value - this is the value of the Attribute. This value can be anything which can be pickled -
+objects, lists, numbers or what have you (see
+this section for more info). In the
+example
+obj.db.attrname=value, the value is stored here.
+
category - this is an optional property that is set to None for most Attributes. Setting this
+allows to use Attributes for different functionality. This is usually not needed unless you want
+to use Attributes for very different functionality (Nicks is an example of using
+Attributes
+in this way). To modify this property you need to use the Attribute
+Handler.
+
strvalue - this is a separate value field that only accepts strings. This severely limits the
+data possible to store, but allows for easier database lookups. This property is usually not used
+except when re-using Attributes for some other purpose (Nicks use it). It is only
+accessible via the Attribute Handler.
+
+
There are also two special properties:
+
+
attrtype - this is used internally by Evennia to separate Nicks, from Attributes (Nicks
+use Attributes behind the scenes).
+
model - this is a natural-key describing the model this Attribute is attached to. This is on
+the form appname.modelclass, like objects.objectdb. It is used by the Attribute and
+NickHandler to quickly sort matches in the database. Neither this nor attrtype should normally
+need to be modified.
+
+
Non-database attributes have no equivalence to category nor strvalue, attrtype or model.
So persistent data means that your data will survive a server reboot, whereas with
+non-persistent data it will not …
+
… So why would you ever want to use non-persistent data? The answer is, you don’t have to. Most of
+the time you really want to save as much as you possibly can. Non-persistent data is potentially
+useful in a few situations though.
+
+
You are worried about database performance. Since Evennia caches Attributes very aggressively,
+this is not an issue unless you are reading and writing to your Attribute very often (like many
+times per second). Reading from an already cached Attribute is as fast as reading any Python
+property. But even then this is not likely something to worry about: Apart from Evennia’s own
+caching, modern database systems themselves also cache data very efficiently for speed. Our
+default
+database even runs completely in RAM if possible, alleviating much of the need to write to disk
+during heavy loads.
+
A more valid reason for using non-persistent data is if you want to lose your state when logging
+off. Maybe you are storing throw-away data that are re-initialized at server startup. Maybe you
+are implementing some caching of your own. Or maybe you are testing a buggy Script that
+does potentially harmful stuff to your character object. With non-persistent storage you can be
+sure
+that whatever is messed up, it’s nothing a server reboot can’t clear up.
+
NAttributes have no restrictions at all on what they can store (see next section), since they
+don’t need to worry about being saved to the database - they work very well for temporary storage.
+
You want to implement a fully or partly non-persistent world. Who are we to argue with your
+grand vision!
None of the following affects NAttributes, which does not invoke the database at all. There are no
+restrictions to what can be stored in a NAttribute.
+
+
The database doesn’t know anything about Python objects, so Evennia must serialize Attribute
+values into a string representation in order to store it to the database. This is done using the
+pickle module of Python (the only exception is if you use the strattr keyword of the
+AttributeHandler to save to the strvalue field of the Attribute. In that case you can only save
+strings which will not be pickled).
+
It’s important to note that when you access the data in an Attribute you are always de-serializing
+it from the database representation every time. This is because we allow for storing
+database-entities in Attributes too. If we cached it as its Python form, we might end up with
+situations where the database entity was deleted since we last accessed the Attribute.
+De-serializing data with a database-entity in it means querying the database for that object and
+making sure it still exists (otherwise it will be set to None). Performance-wise this is usually
+not a big deal. But if you are accessing the Attribute as part of some big loop or doing a large
+amount of reads/writes you should first extract it to a temporary variable, operate on that and
+then save the result back to the Attribute. If you are storing a more complex structure like a
+dict or a list you should make sure to “disconnect” it from the database before looping over it,
+as mentioned in the Retrieving Mutable Objects section
+below.
With a single object, we mean anything that is not iterable, like numbers, strings or custom class
+instances without the __iter__ method.
+
+
You can generally store any non-iterable Python entity that can be
+pickled.
+
Single database objects/typeclasses can be stored as any other in the Attribute. These can
+normally not be pickled, but Evennia will behind the scenes convert them to an internal
+representation using their classname, database-id and creation-date with a microsecond precision,
+guaranteeing you get the same object back when you access the Attribute later.
+
If you hide a database object inside a non-iterable custom class (like stored as a variable
+inside it), Evennia will not know it’s there and won’t convert it safely. Storing classes with
+such hidden database objects is not supported and will lead to errors!
+
+
# Examples of valid single-value attribute data:
+obj.db.test1=23
+obj.db.test1=False
+# a database object (will be stored as an internal representation)
+obj.db.test2=myobj
+
+# example of an invalid, "hidden" dbobject
+classInvalid(object):
+ def__init__(self,dbobj):
+ # no way for Evennia to know this is a dbobj
+ self.dbobj=dbobj
+invalid=Invalid(myobj)
+obj.db.invalid=invalid# will cause error!
+
This means storing objects in a collection of some kind and are examples of iterables, pickle-able
+entities you can loop over in a for-loop. Attribute-saving supports the following iterables:
Nestings of any combinations of the above, like lists in dicts or an OrderedDict of tuples, each
+containing dicts, etc.
+
All other iterables (i.e. entities with the __iter__ method) will be converted to a list.
+Since you can use any combination of the above iterables, this is generally not much of a
+limitation.
+
+
Any entity listed in the Single object section above can be
+stored in the iterable.
+
+
As mentioned in the previous section, database entities (aka typeclasses) are not possible to
+pickle. So when storing an iterable, Evennia must recursively traverse the iterable and all its
+nested sub-iterables in order to find eventual database objects to convert. This is a very fast
+process but for efficiency you may want to avoid too deeply nested structures if you can.
+
+
# examples of valid iterables to store
+obj.db.test3=[obj1,45,obj2,67]
+# a dictionary
+obj.db.test4={'str':34,'dex':56,'agi':22,'int':77}
+# a mixed dictionary/list
+obj.db.test5={'members':[obj1,obj2,obj3],'enemies':[obj4,obj5]}
+# a tuple with a list in it
+obj.db.test6=(1,3,4,8,["test","test2"],9)
+# a set
+obj.db.test7=set([1,2,3,4,5])
+# in-situ manipulation
+obj.db.test8=[1,2,{"test":1}]
+obj.db.test8[0]=4
+obj.db.test8[2]["test"]=5
+# test8 is now [4,2,{"test":5}]
+
A side effect of the way Evennia stores Attributes is that mutable iterables (iterables that can
+be modified in-place after they were created, which is everything except tuples) are handled by
+custom objects called _SaverList, _SaverDict etc. These _Saver... classes behave just like the
+normal variant except that they are aware of the database and saves to it whenever new data gets
+assigned to them. This is what allows you to do things like self.db.mylist[7]=val and be sure
+that the new version of list is saved. Without this you would have to load the list into a temporary
+variable, change it and then re-assign it to the Attribute in order for it to save.
+
There is however an important thing to remember. If you retrieve your mutable iterable into another
+variable, e.g. mylist2=obj.db.mylist, your new variable (mylist2) will still be a
+_SaverList. This means it will continue to save itself to the database whenever it is updated!
+
obj.db.mylist=[1,2,3,4]
+ mylist=obj.db.mylist
+ mylist[3]=5# this will also update database
+ print(mylist)# this is now [1,2,3,5]
+ print(obj.db.mylist)# this is also [1,2,3,5]
+
+
+
To “disconnect” your extracted mutable variable from the database you simply need to convert the
+_Saver... iterable to a normal Python structure. So to convert a _SaverList, you use the
+list() function, for a _SaverDict you use dict() and so on.
+
obj.db.mylist=[1,2,3,4]
+ mylist=list(obj.db.mylist)# convert to normal list
+ mylist[3]=5
+ print(mylist)# this is now [1,2,3,5]
+ print(obj.db.mylist)# this is still [1,2,3,4]
+
+
+
A further problem comes with nested mutables, like a dict containing lists of dicts or something
+like that. Each of these nested mutables would be _Saver* structures connected to the database and
+disconnecting the outermost one of them would not disconnect those nested within. To make really
+sure you disonnect a nested structure entirely from the database, Evennia provides a special
+function evennia.utils.dbserialize.deserialize:
The result of this operation will be a structure only consisting of normal Python mutables (list
+instead of _SaverList and so on).
+
Remember, this is only valid for mutable iterables.
+Immutable objects (strings, numbers, tuples etc) are
+already disconnected from the database from the onset.
+
obj.db.mytup=(1,2,[3,4])
+ obj.db.mytup[0]=5# this fails since tuples are immutable
+
+ # this works but will NOT update database since outermost is a tuple
+ obj.db.mytup[2][1]=5
+ print(obj.db.mytup[2][1])# this still returns 4, not 5
+
+ mytup1=obj.db.mytup# mytup1 is already disconnected from database since outermost
+ # iterable is a tuple, so we can edit the internal list as we want
+ # without affecting the database.
+
+
+
+
Attributes will fetch data fresh from the database whenever you read them, so
+if you are performing big operations on a mutable Attribute property (such as looping over a list
+or dict) you should make sure to “disconnect” the Attribute’s value first and operate on this
+rather than on the Attribute. You can gain dramatic speed improvements to big loops this
+way.
Attributes are normally not locked down by default, but you can easily change that for individual
+Attributes (like those that may be game-sensitive in games with user-level building).
+
First you need to set a lock string on your Attribute. Lock strings are specified Locks.
+The relevant lock types are
+
+
attrread - limits who may read the value of the Attribute
+
attredit - limits who may set/change this Attribute
+
+
You cannot use the db handler to modify Attribute object (such as setting a lock on them) - The
+db handler will return the Attribute’s value, not the Attribute object itself. Instead you use
+the AttributeHandler and set it to return the object instead of the value:
Note the return_obj keyword which makes sure to return the Attribute object so its LockHandler
+could be accessed.
+
A lock is no good if nothing checks it – and by default Evennia does not check locks on Attributes.
+You have to add a check to your commands/code wherever it fits (such as before setting an
+Attribute).
+
# in some command code where we want to limit
+ # setting of a given attribute name on an object
+ attr=obj.attributes.get(attrname,
+ return_obj=True,
+ accessing_obj=caller,
+ default=None,
+ default_access=False)
+ ifnotattr:
+ caller.msg("You cannot edit that Attribute!")
+ return
+ # edit the Attribute here
+
+
+
The same keywords are available to use with obj.attributes.set() and obj.attributes.remove(),
+those will check for the attredit lock type.
Whether due to abuse, blatant breaking of your rules, or some other reason, you will eventually find
+no other recourse but to kick out a particularly troublesome player. The default command set has
+admin tools to handle this, primarily ban, unban, and boot.
Say we have a troublesome player “YouSuck” - this is a person that refuses common courtesy - an
+abusive
+and spammy account that is clearly created by some bored internet hooligan only to cause grief. You
+have tried to be nice. Now you just want this troll gone.
The easiest recourse is to block the account YouSuck from ever connecting again.
+
ban YouSuck
+
+
+
This will lock the name YouSuck (as well as ‘yousuck’ and any other capitalization combination), and
+next time they try to log in with this name the server will not let them!
+
You can also give a reason so you remember later why this was a good thing (the banned account will
+never see this)
+
ban YouSuck:This is just a troll.
+
+
+
If you are sure this is just a spam account, you might even consider deleting the player account
+outright:
+
account/delete YouSuck
+
+
+
Generally, banning the name is the easier and safer way to stop the use of an account – if you
+change your mind you can always remove the block later whereas a deletion is permanent.
Just because you block YouSuck’s name might not mean the trolling human behind that account gives
+up. They can just create a new account YouSuckMore and be back at it. One way to make things harder
+for them is to tell the server to not allow connections from their particular IP address.
+
First, when the offending account is online, check which IP address they use. This you can do with
+the who command, which will show you something like this:
+
Account Name On for Idle Room Cmds Host
+ YouSuckMore 01:12 2m 22 212 237.333.0.223
+
+
+
The “Host” bit is the IP address from which the account is connecting. Use this to define the ban
+instead of the name:
+
ban 237.333.0.223
+
+
+
This will stop YouSuckMore connecting from their computer. Note however that IP address might change
+easily - either due to how the player’s Internet Service Provider operates or by the user simply
+changing computers. You can make a more general ban by putting asterisks * as wildcards for the
+groups of three digits in the address. So if you figure out that !YouSuckMore mainly connects from
+237.333.0.223, 237.333.0.225, and 237.333.0.256 (only changes in their subnet), it might be an idea
+to put down a ban like this to include any number in that subnet:
+
ban 237.333.0.*
+
+
+
You should combine the IP ban with a name-ban too of course, so the account YouSuckMore is truly
+locked regardless of where they connect from.
+
Be careful with too general IP bans however (more asterisks above). If you are unlucky you could be
+blocking out innocent players who just happen to connect from the same subnet as the offender.
YouSuck is not really noticing all this banning yet though - and won’t until having logged out and
+trying to log back in again. Let’s help the troll along.
+
boot YouSuck
+
+
+
Good riddance. You can give a reason for booting too (to be echoed to the player before getting
+kicked out).
Below are other useful commands for dealing with annoying players.
+
+
who – (as admin) Find the IP of a account. Note that one account can be connected to from
+multiple IPs depending on what you allow in your settings.
+
examine/account thomas – Get all details about an account. You can also use *thomas to get
+the account. If not given, you will get the Object thomas if it exists in the same location, which
+is not what you want in this case.
+
boot thomas – Boot all sessions of the given account name.
+
boot 23 – Boot one specific client session/IP by its unique id.
+
ban – List all bans (listed with ids)
+
ban thomas – Ban the user with the given account name
+
ban/ip 134.233.2.111 – Ban by IP
+
ban/ip 134.233.2.* – Widen IP ban
+
ban/ip 134.233.*.* – Even wider IP ban
+
unban 34 – Remove ban with id #34
+
cboot mychannel = thomas – Boot a subscriber from a channel you control
+
clock mychannel = control:perm(Admin);listen:all();send:all() – Fine control of access to
+your channel using lock definitions.
+
+
Locking a specific command (like page) is accomplished like so:
+
+
Examine the source of the command. The default page command class has the lock
+string “cmd:not pperm(page_banned)”. This means that unless the player has the ‘permission’
+“page_banned” they can use this command. You can assign any lock string to allow finer customization
+in your commands. You might look for the value of an Attribute or Tag, your
+current location etc.
+
perm/account thomas = page_banned – Give the account the ‘permission’ which causes (in this
+case) the lock to fail.
+
+
+
perm/del/account thomas = page_banned – Remove the given permission
+
tel thomas = jail – Teleport a player to a specified location or #dbref
+
type thomas = FlowerPot – Turn an annoying player into a flower pot (assuming you have a
+FlowerPot typeclass ready)
+
userpassword thomas = fooBarFoo – Change a user’s password
+
account/delete thomas – Delete a player account (not recommended, use ban instead)
+
server – Show server statistics, such as CPU load, memory usage, and how many objects are
+cached
+
time – Gives server uptime, runtime, etc
+
reload – Reloads the server without disconnecting anyone
+
reset – Restarts the server, kicking all connections
+
shutdown – Stops the server cold without it auto-starting again
+
py – Executes raw Python code, allows for direct inspection of the database and account
+objects on the fly. For advanced users.
+
+
Useful Tip:evenniachangepassword<username> entered into the command prompt will reset the
+password of any account, including the superuser or admin accounts. This is a feature of Django.
For an introduction and motivation to using batch processors, see here. This
+page describes the Batch-code processor. The Batch-command one is covered [here](Batch-Command-
+Processor).
The batch-code processor is a superuser-only function, invoked by
+
> @batchcode path.to.batchcodefile
+
+
+
Where path.to.batchcodefile is the path to a batch-code file. Such a file should have a name
+ending in “.py” (but you shouldn’t include that in the path). The path is given like a python path
+relative to a folder you define to hold your batch files, set by BATCH_IMPORT_PATH in your
+settings. Default folder is (assuming your game is called “mygame”) mygame/world/. So if you want
+to run the example batch file in mygame/world/batch_code.py, you could simply use
+
> @batchcode batch_code
+
+
+
This will try to run through the entire batch file in one go. For more gradual, interactive
+control you can use the /interactive switch. The switch /debug will put the processor in
+debug mode. Read below for more info.
A batch-code file is a normal Python file. The difference is that since the batch processor loads
+and executes the file rather than importing it, you can reliably update the file, then call it
+again, over and over and see your changes without needing to @reload the server. This makes for
+easy testing. In the batch-code file you have also access to the following global variables:
+
+
caller - This is a reference to the object running the batchprocessor.
+
DEBUG - This is a boolean that lets you determine if this file is currently being run in debug-
+mode or not. See below how this can be useful.
+
+
Running a plain Python file through the processor will just execute the file from beginning to end.
+If you want to get more control over the execution you can use the processor’s interactive mode.
+This runs certain code blocks on their own, rerunning only that part until you are happy with it. In
+order to do this you need to add special markers to your file to divide it up into smaller chunks.
+These take the form of comments, so the file remains valid Python.
+
Here are the rules of syntax of the batch-code *.py file.
+
+
#CODE as the first on a line marks the start of a code block. It will last until the beginning
+of another marker or the end of the file. Code blocks contain functional python code. Each #CODE
+block will be run in complete isolation from other parts of the file, so make sure it’s self-
+contained.
+
#HEADER as the first on a line marks the start of a header block. It lasts until the next
+marker or the end of the file. This is intended to hold imports and variables you will need for all
+other blocks .All python code defined in a header block will always be inserted at the top of every
+#CODE blocks in the file. You may have more than one #HEADER block, but that is equivalent to
+having one big one. Note that you can’t exchange data between code blocks, so editing a header-
+variable in one code block won’t affect that variable in any other code block!
+
#INSERTpath.to.file will insert another batchcode (Python) file at that position.
+
A # that is not starting a #HEADER, #CODE or #INSERT instruction is considered a comment.
+
Inside a block, normal Python syntax rules apply. For the sake of indentation, each block acts as
+a separate python module.
+
+
Below is a version of the example file found in evennia/contrib/tutorial_examples/.
+
#
+ # This is an example batch-code build file for Evennia.
+ #
+
+ #HEADER
+
+ # This will be included in all other #CODE blocks
+
+ fromevenniaimportcreate_object,search_object
+ fromevennia.contrib.tutorial_examplesimportred_button
+ fromtypeclasses.objectsimportObject
+
+ limbo=search_object('Limbo')[0]
+
+
+ #CODE
+
+ red_button=create_object(red_button.RedButton,key="Red button",
+ location=limbo,aliases=["button"])
+
+ # caller points to the one running the script
+ caller.msg("A red button was created.")
+
+ # importing more code from another batch-code file
+ #INSERT batch_code_insert
+
+ #CODE
+
+ table=create_object(Object,key="Blue Table",location=limbo)
+ chair=create_object(Object,key="Blue Chair",location=limbo)
+
+ string="A %s and %s were created."
+ ifDEBUG:
+ table.delete()
+ chair.delete()
+ string+=" Since debug was active, " \
+ "they were deleted again."
+ caller.msg(string%(table,chair))
+
+
+
This uses Evennia’s Python API to create three objects in sequence.
The batch script will run to the end and tell you it completed. You will also get messages that the
+button and the two pieces of furniture were created. Look around and you should see the button
+there. But you won’t see any chair nor a table! This is because we ran this with the /debug
+switch, which is directly visible as DEBUG==True inside the script. In the above example we
+handled this state by deleting the chair and table again.
+
The debug mode is intended to be used when you test out a batchscript. Maybe you are looking for
+bugs in your code or try to see if things behave as they should. Running the script over and over
+would then create an ever-growing stack of chairs and tables, all with the same name. You would have
+to go back and painstakingly delete them later.
Interactive mode works very similar to the [batch-command processor counterpart](Batch-Command-
+Processor). It allows you more step-wise control over how the batch file is executed. This is useful
+for debugging or for picking and choosing only particular blocks to run. Use @batchcode with the
+/interactive flag to enter interactive mode.
01/02: red_button = create_object(red_button.RedButton, [...] (hh for help)
+
+
+
This shows that you are on the first #CODE block, the first of only two commands in this batch
+file. Observe that the block has not actually been executed at this point!
+
To take a look at the full code snippet you are about to run, use ll (a batch-processor version of
+look).
+
fromevennia.utilsimportcreate,search
+ fromevennia.contrib.tutorial_examplesimportred_button
+ fromtypeclasses.objectsimportObject
+
+ limbo=search.objects(caller,'Limbo',global_search=True)[0]
+
+ red_button=create.create_object(red_button.RedButton,key="Red button",
+ location=limbo,aliases=["button"])
+
+ # caller points to the one running the script
+ caller.msg("A red button was created.")
+
+
+
Compare with the example code given earlier. Notice how the content of #HEADER has been pasted at
+the top of the #CODE block. Use pp to actually execute this block (this will create the button
+and give you a message). Use nn (next) to go to the next command. Use hh for a list of commands.
+
If there are tracebacks, fix them in the batch file, then use rr to reload the file. You will
+still be at the same code block and can rerun it easily with pp as needed. This makes for a simple
+debug cycle. It also allows you to rerun individual troublesome blocks - as mentioned, in a large
+batch file this can be very useful (don’t forget the /debug mode either).
+
Use nn and bb (next and back) to step through the file; e.g. nn12 will jump 12 steps forward
+(without processing any blocks in between). All normal commands of Evennia should work too while
+working in interactive mode.
Or rather the lack of it. There is a reason only superusers are allowed to run the batch-code
+processor by default. The code-processor runs without any Evennia security checks and allows
+full access to Python. If an untrusted party could run the code-processor they could execute
+arbitrary python code on your machine, which is potentially a very dangerous thing. If you want to
+allow other users to access the batch-code processor you should make sure to run Evennia as a
+separate and very limited-access user on your machine (i.e. in a ‘jail’). By comparison, the batch-
+command processor is much safer since the user running it is still ‘inside’ the game and can’t
+really do anything outside what the game commands allow them to.
Global variables won’t work in code batch files, each block is executed as stand-alone environments.
+#HEADER blocks are literally pasted on top of each #CODE block so updating some header-variable
+in your block will not make that change available in another block. Whereas a python execution
+limitation, allowing this would also lead to very hard-to-debug code when using the interactive mode
+
+
this would be a classical example of “spaghetti code”.
+
+
The main practical issue with this is when building e.g. a room in one code block and later want to
+connect that room with a room you built in the current block. There are two ways to do this:
+
+
Perform a database search for the name of the room you created (since you cannot know in advance
+which dbref it got assigned). The problem is that a name may not be unique (you may have a lot of “A
+dark forest” rooms). There is an easy way to handle this though - use Tags or Aliases. You
+can assign any number of tags and/or aliases to any object. Make sure that one of those tags or
+aliases is unique to the room (like “room56”) and you will henceforth be able to always uniquely
+search and find it later.
+
Use the caller global property as an inter-block storage. For example, you could have a
+dictionary of room references in an ndb:
+
#HEADER
+ifcaller.ndb.all_roomsisNone:
+ caller.ndb.all_rooms={}
+
+#CODE
+# create and store the castle
+castle=create_object("rooms.Room",key="Castle")
+caller.ndb.all_rooms["castle"]=castle
+
+#CODE
+# in another node we want to access the castle
+castle=caller.ndb.all_rooms.get("castle")
+
+
+
+
+
Note how we check in #HEADER if caller.ndb.all_rooms doesn’t already exist before creating the
+dict. Remember that #HEADER is copied in front of every #CODE block. Without that if statement
+we’d be wiping the dict every block!
+
+
+
Don’t treat a batchcode file like any Python file¶
+
Despite being a valid Python file, a batchcode file should only be run by the batchcode processor.
+You should not do things like define Typeclasses or Commands in them, or import them into other
+code. Importing a module in Python will execute base level of the module, which in the case of your
+average batchcode file could mean creating a lot of new objects every time.
+
+
+
Don’t let code rely on the batch-file’s real file path¶
+
When you import things into your batchcode file, don’t use relative imports but always import with
+paths starting from the root of your game directory or evennia library. Code that relies on the
+batch file’s “actual” location will fail. Batch code files are read as text and the strings
+executed. When the code runs it has no knowledge of what file those strings where once a part of.
For an introduction and motivation to using batch processors, see here. This
+page describes the Batch-command processor. The Batch-code one is covered [here](Batch-Code-
+Processor).
The batch-command processor is a superuser-only function, invoked by
+
> @batchcommand path.to.batchcmdfile
+
+
+
Where path.to.batchcmdfile is the path to a batch-command file with the “.ev” file ending.
+This path is given like a python path relative to a folder you define to hold your batch files, set
+with BATCH_IMPORT_PATH in your settings. Default folder is (assuming your game is in the mygame
+folder) mygame/world. So if you want to run the example batch file in
+mygame/world/batch_cmds.ev, you could use
+
> @batchcommand batch_cmds
+
+
+
A batch-command file contains a list of Evennia in-game commands separated by comments. The
+processor will run the batch file from beginning to end. Note that it will not stop if commands in
+it fail (there is no universal way for the processor to know what a failure looks like for all
+different commands). So keep a close watch on the output, or use Interactive mode (see below) to
+run the file in a more controlled, gradual manner.
The batch file is a simple plain-text file containing Evennia commands. Just like you would write
+them in-game, except you have more freedom with line breaks.
+
Here are the rules of syntax of an *.ev file. You’ll find it’s really, really simple:
+
+
All lines having the # (hash)-symbol as the first one on the line are considered comments.
+All non-comment lines are treated as a command and/or their arguments.
+
Comment lines have an actual function – they mark the end of the previous command definition.
+So never put two commands directly after one another in the file - separate them with a comment, or
+the second of the two will be considered an argument to the first one. Besides, using plenty of
+comments is good practice anyway.
+
A line that starts with the word #INSERT is a comment line but also signifies a special
+instruction. The syntax is #INSERT<path.batchfile> and tries to import a given batch-cmd file
+into this one. The inserted batch file (file ending .ev) will run normally from the point of the
+#INSERT instruction.
+
Extra whitespace in a command definition is ignored. - A completely empty line translates in to
+a line break in texts. Two empty lines thus means a new paragraph (this is obviously only relevant
+for commands accepting such formatting, such as the @desc command).
+
The very last command in the file is not required to end with a comment.
+
You cannot nest another @batchcommand statement into your batch file. If you want to link many
+batch-files together, use the #INSERT batch instruction instead. You also cannot launch the
+@batchcode command from your batch file, the two batch processors are not compatible.
+
+
Below is a version of the example file found in evennia/contrib/tutorial_examples/batch_cmds.ev.
+
#
+ # This is an example batch build file for Evennia.
+ #
+
+ # This creates a red button
+ @create button:tutorial_examples.red_button.RedButton
+ # (This comment ends input for @create)
+ # Next command. Let's create something.
+ @set button/desc =
+ This is a large red button. Now and then
+ it flashes in an evil, yet strangely tantalizing way.
+
+ A big sign sits next to it. It says:
+
+
+ -----------
+
+ Press me!
+
+ -----------
+
+
+ ... It really begs to be pressed! You
+ know you want to!
+
+ # This inserts the commands from another batch-cmd file named
+ # batch_insert_file.ev.
+ #INSERT examples.batch_insert_file
+
+
+ # (This ends the @set command). Note that single line breaks
+ # and extra whitespace in the argument are ignored. Empty lines
+ # translate into line breaks in the output.
+ # Now let's place the button where it belongs (let's say limbo #2 is
+ # the evil lair in our example)
+ @teleport #2
+ # (This comments ends the @teleport command.)
+ # Now we drop it so others can see it.
+ # The very last command in the file needs not be ended with #.
+ drop button
+
A button will be created, described and dropped in Limbo. All commands will be executed by the user
+calling the command.
+
+
Note that if you interact with the button, you might find that its description changes, loosing
+your custom-set description above. This is just the way this particular object works.
Interactive mode allows you to more step-wise control over how the batch file is executed. This is
+useful for debugging and also if you have a large batch file and is only updating a small part of it
+– running the entire file again would be a waste of time (and in the case of @create-ing objects
+you would to end up with multiple copies of same-named objects, for example). Use @batchcommand
+with the /interactive flag to enter interactive mode.
01/04: @create button:tutorial_examples.red_button.RedButton (hh for help)
+
+
+
This shows that you are on the @create command, the first out of only four commands in this batch
+file. Observe that the command @create has not been actually processed at this point!
+
To take a look at the full command you are about to run, use ll (a batch-processor version of
+look). Use pp to actually process the current command (this will actually @create the button)
+– and make sure it worked as planned. Use nn (next) to go to the next command. Use hh for a
+list of commands.
+
If there are errors, fix them in the batch file, then use rr to reload the file. You will still be
+at the same command and can rerun it easily with pp as needed. This makes for a simple debug
+cycle. It also allows you to rerun individual troublesome commands - as mentioned, in a large batch
+file this can be very useful. Do note that in many cases, commands depend on the previous ones (e.g.
+if @create in the example above had failed, the following commands would have had nothing to
+operate on).
+
Use nn and bb (next and back) to step through the file; e.g. nn12 will jump 12 steps forward
+(without processing any command in between). All normal commands of Evennia should work too while
+working in interactive mode.
The batch-command processor is great for automating smaller builds or for testing new commands and
+objects repeatedly without having to write so much. There are several caveats you have to be aware
+of when using the batch-command processor for building larger, complex worlds though.
+
The main issue is that when you run a batch-command script you (you, as in your superuser
+character) are actually moving around in the game creating and building rooms in sequence, just as
+if you had been entering those commands manually, one by one. You have to take this into account
+when creating the file, so that you can ‘walk’ (or teleport) to the right places in order.
+
This also means there are several pitfalls when designing and adding certain types of objects. Here
+are some examples:
+
+
Rooms that change your Command Set: Imagine that you build a ‘dark’ room, which
+severely limits the cmdsets of those entering it (maybe you have to find the light switch to
+proceed). In your batch script you would create this room, then teleport to it - and promptly be
+shifted into the dark state where none of your normal build commands work …
+
Auto-teleportation: Rooms that automatically teleport those that enter them to another place
+(like a trap room, for example). You would be teleported away too.
+
Mobiles: If you add aggressive mobs, they might attack you, drawing you into combat. If they
+have AI they might even follow you around when building - or they might move away from you before
+you’ve had time to finish describing and equipping them!
+
+
The solution to all these is to plan ahead. Make sure that superusers are never affected by whatever
+effects are in play. Add an on/off switch to objects and make sure it’s always set to off upon
+creation. It’s all doable, one just needs to keep it in mind.
The fact that you build as ‘yourself’ can also be considered an advantage however, should you ever
+decide to change the default command to allow others than superusers to call the processor. Since
+normal access-checks are still performed, a malevolent builder with access to the processor should
+not be able to do all that much damage (this is the main drawback of the Batch Code
+Processor)
+
+
GNU Emacs users might find it interesting to use emacs’
+evennia mode. This is an Emacs major mode found in evennia/utils/evennia-mode.el. It offers
+correct syntax highlighting and indentation with <tab> when editing .ev files in Emacs. See the
+header of that file for installation instructions.
+
VIM users can use amfl’s vim-evennia
+mode instead, see its readme for install instructions.
Building a game world is a lot of work, especially when starting out. Rooms should be created,
+descriptions have to be written, objects must be detailed and placed in their proper places. In many
+traditional MUD setups you had to do all this online, line by line, over a telnet session.
+
Evennia already moves away from much of this by shifting the main coding work to external Python
+modules. But also building would be helped if one could do some or all of it externally. Enter
+Evennia’s batch processors (there are two of them). The processors allows you, as a game admin, to
+build your game completely offline in normal text files (batch files) that the processors
+understands. Then, when you are ready, you use the processors to read it all into Evennia (and into
+the database) in one go.
+
You can of course still build completely online should you want to - this is certainly the easiest
+way to go when learning and for small build projects. But for major building work, the advantages of
+using the batch-processors are many:
+
+
It’s hard to compete with the comfort of a modern desktop text editor; Compared to a traditional
+MUD line input, you can get much better overview and many more features. Also, accidentally pressing
+Return won’t immediately commit things to the database.
+
You might run external spell checkers on your batch files. In the case of one of the batch-
+processors (the one that deals with Python code), you could also run external debuggers and code
+analyzers on your file to catch problems before feeding it to Evennia.
+
The batch files (as long as you keep them) are records of your work. They make a natural starting
+point for quickly re-building your world should you ever decide to start over.
+
If you are an Evennia developer, using a batch file is a fast way to setup a test-game after
+having reset the database.
+
The batch files might come in useful should you ever decide to distribute all or part of your
+world to others.
+
+
There are two batch processors, the Batch-command processor and the Batch-code processor. The
+first one is the simpler of the two. It doesn’t require any programming knowledge - you basically
+just list in-game commands in a text file. The code-processor on the other hand is much more
+powerful but also more complex - it lets you use Evennia’s API to code your world in full-fledged
+Python code.
As mentioned, both the processors take text files as input and then proceed to process them. As long
+as you stick to the standard ASCII character set (which means
+the normal English characters, basically) you should not have to worry much about this section.
+
Many languages however use characters outside the simple ASCII table. Common examples are various
+apostrophes and umlauts but also completely different symbols like those of the greek or cyrillic
+alphabets.
+
First, we should make it clear that Evennia itself handles international characters just fine. It
+(and Django) uses unicode strings internally.
+
The problem is that when reading a text file like the batchfile, we need to know how to decode the
+byte-data stored therein to universal unicode. That means we need an encoding (a mapping) for how
+the file stores its data. There are many, many byte-encodings used around the world, with opaque
+names such as Latin-1, ISO-8859-3 or ARMSCII-8 to pick just a few examples. Problem is that
+it’s practially impossible to determine which encoding was used to save a file just by looking at it
+(it’s just a bunch of bytes!). You have to know.
+
With this little introduction it should be clear that Evennia can’t guess but has to assume an
+encoding when trying to load a batchfile. The text editor and Evennia must speak the same “language”
+so to speak. Evennia will by default first try the international UTF-8 encoding, but you can have
+Evennia try any sequence of different encodings by customizing the ENCODINGS list in your settings
+file. Evennia will use the first encoding in the list that do not raise any errors. Only if none
+work will the server give up and return an error message.
+
You can often change the text editor encoding (this depends on your editor though), otherwise you
+need to add the editor’s encoding to Evennia’s ENCODINGS list. If you are unsure, write a test
+file with lots of non-ASCII letters in the editor of your choice, then import to make sure it works
+as it should.
+
More help with encodings can be found in the entry Text Encodings and also in the
+Wikipedia article here.
+
A footnote for the batch-code processor: Just because Evennia can parse your file and your
+fancy special characters, doesn’t mean that Python allows their use. Python syntax only allows
+international characters inside strings. In all other source code only ASCII set characters are
+allowed.
Evennia’s new default web page uses a framework called Bootstrap. This
+framework is in use across the internet - you’ll probably start to recognize its influence once you
+learn some of the common design patterns. This switch is great for web developers, perhaps like
+yourself, because instead of wondering about setting up different grid systems or what custom class
+another designer used, we have a base, a bootstrap, to work from. Bootstrap is responsive by
+default, and comes with some default styles that Evennia has lightly overrode to keep some of the
+same colors and styles you’re used to from the previous design.
+
For your reading pleasure, a brief overview of Bootstrap follows. For more in-depth info, please
+read the documentation.
The container is meant to hold all your page content. Bootstrap provides two types: fixed-width and
+full-width.
+Fixed-width containers take up a certain max-width of the page - they’re useful for limiting the
+width on Desktop or Tablet platforms, instead of making the content span the width of the page.
+
<div class="container">
+ <!--- Your content here -->
+</div>
+
+
+
Full width containers take up the maximum width available to them - they’ll span across a wide-
+screen desktop or a smaller screen phone, edge-to-edge.
+
<div class="container-fluid">
+ <!--- This content will span the whole page -->
+</div>
+
+
+
The second part of the layout system is the grid.
+This is the bread-and-butter of the layout of Bootstrap - it allows you to change the size of
+elements depending on the size of the screen, without writing any media queries. We’ll briefly go
+over it - to learn more, please read the docs or look at the source code for Evennia’s home page in
+your browser.
+
+
Important! Grid elements should be in a .container or .container-fluid. This will center the
+contents of your site.
+
+
Bootstrap’s grid system allows you to create rows and columns by applying classes based on
+breakpoints. The default breakpoints are extra small, small, medium, large, and extra-large. If
+you’d like to know more about these breakpoints, please take a look at the documentation for
+them.
+
To use the grid system, first create a container for your content, then add your rows and columns
+like so:
This layout would create three equal-width columns.
+
To specify your sizes - for instance, Evennia’s default site has three columns on desktop and
+tablet, but reflows to single-column on smaller screens. Try it out!
Bootstrap also provides a huge amount of utilities, as well as styling and content elements. To
+learn more about them, please read the Bootstrap docs or read one of our other web tutorials.
Bootstrap provides many utilities and components you can use when customizing Evennia’s web
+presence. We’ll go over a few examples here that you might find useful.
Bootstrap provides base styles for your site. These can be customized through CSS, but the default
+styles are intended to provide a consistent, clean look for sites.
Bootstrap provides classes to easily add responsive margin and padding. Most of the time, you might
+like to add margins or padding through CSS itself - however these classes are used in the default
+Evennia site. Take a look at the docs to
+learn more.
Cards provide a container for other elements
+that stands out from the rest of the page. The “Accounts”, “Recently Connected”, and “Database
+Stats” on the default webpage are all in cards. Cards provide quite a bit of formatting options -
+the following is a simple example, but read the documentation or look at the site’s source for more.
Jumbotrons are useful for featuring an
+image or tagline for your game. They can flow with the rest of your content or take up the full
+width of the page - Evennia’s base site uses the former.
Forms are highly customizable with Bootstrap.
+For a more in-depth look at how to use forms and their styles in your own Evennia site, please read
+over the web character gen tutorial.
There are strictly speaking two types of users in Evennia, the super user and everyone else. The
+superuser is the first user you create, object #1. This is the all-powerful server-owner account.
+Technically the superuser not only has access to everything, it bypasses the permission checks
+entirely. This makes the superuser impossible to lock out, but makes it unsuitable to actually play-
+test the game’s locks and restrictions with (see @quell below). Usually there is no need to have
+but one superuser.
Whereas permissions can be used for anything, those put in settings.PERMISSION_HIERARCHY will have
+a ranking relative each other as well. We refer to these types of permissions as hierarchical
+permissions. When building locks to check these permissions, the perm()lock function is
+used. By default Evennia creates the following hierarchy (spelled exactly like this):
+
+
Developers basically have the same access as superusers except that they do not sidestep
+the Permission system. Assign only to really trusted server-admin staff since this level gives
+access both to server reload/shutdown functionality as well as (and this may be more critical) gives
+access to the all-powerful @py command that allows the execution of arbitrary Python code on the
+command line.
+
Admins can do everything except affecting the server functions themselves. So an Admin
+couldn’t reload or shutdown the server for example. They also cannot execute arbitrary Python code
+on the console or import files from the hard drive.
+
Builders - have all the build commands, but cannot affect other accounts or mess with the
+server.
+
Helpers are almost like a normal Player, but they can also add help files to the database.
+
Players is the default group that new players end up in. A new player have permission to use
+tells and to use and create new channels.
+
+
A user having a certain level of permission automatically have access to locks specifying access of
+a lower level.
+
To assign a new permission from inside the game, you need to be able to use the @perm command.
+This is an Developer-level command, but it could in principle be made lower-access since it only
+allows assignments equal or lower to your current level (so you cannot use it to escalate your own
+permission level). So, assuming you yourself have Developer access (or is superuser), you assign
+a new account “Tommy” to your core staff with the command
+
@perm/account Tommy = Developer
+
+
+
or
+
@perm *Tommy = Developer
+
+
+
We use a switch or the *name format to make sure to put the permission on the Account and not on
+any eventual Character that may also be named “Tommy”. This is usually what you want since the
+Account will then remain an Developer regardless of which Character they are currently controlling.
+To limit permission to a per-Character level you should instead use quelling (see below). Normally
+permissions can be any string, but for these special hierarchical permissions you can also use
+plural (“Developer” and “Developers” both grant the same powers).
When developing it can be useful to check just how things would look had your permission-level been
+lower. For this you can use quelling. Normally, when you puppet a Character you are using your
+Account-level permission. So even if your Character only has Accounts level permissions, your
+Developer-level Account will take precedence. With the @quell command you can change so that the
+Character’s permission takes precedence instead:
+
@quell
+
+
+
This will allow you to test out the game using the current Character’s permission level. A developer
+or builder can thus in principle maintain several test characters, all using different permission
+levels. Note that you cannot escalate your permissions this way; If the Character happens to have a
+higher permission level than the Account, the Account’s (lower) permission will still be used.
The default command definitions coming with Evennia
+follows a style similar to that of MUX, so the
+commands should be familiar if you used any such code bases before.
+
+
Throughout the larger documentation you may come across commands prefixed
+with @. This is just an optional marker used in some places to make a
+command stand out. Evennia defaults to ignoring the use of @ in front of
+your command (so entering dig is the same as entering @dig).
+
+
The default commands have the following style (where [...] marks optional parts):
+
command[/switch/switch...] [arguments ...]
+
+
+
A switch is a special, optional flag to the command to make it behave differently. It is always
+put directly after the command name, and begins with a forward slash (/). The arguments are one
+or more inputs to the commands. It’s common to use an equal sign (=) when assigning something to
+an object.
+
Below are some examples of commands you can try when logged in to the game. Use help<command> for
+learning more about each command and their detailed options.
If you just installed Evennia, your very first player account is called user #1, also known as the
+superuser or god user. This user is very powerful, so powerful that it will override many game
+restrictions such as locks. This can be useful, but it also hides some functionality that you might
+want to test.
+
To temporarily step down from your superuser position you can use the quell command in-game:
+
quell
+
+
+
This will make you start using the permission of your current character’s level instead of your
+superuser level. If you didn’t change any settings your game Character should have an Developer
+level permission - high as can be without bypassing locks like the superuser does. This will work
+fine for the examples on this page. Use unquell to get back to superuser status again afterwards.
Basic objects can be anything – swords, flowers and non-player characters. They are created using
+the create command:
+
create box
+
+
+
This created a new ‘box’ (of the default object type) in your inventory. Use the command inventory
+(or i) to see it. Now, ‘box’ is a rather short name, let’s rename it and tack on a few aliases.
+
name box = very large box;box;very;crate
+
+
+
We now renamed the box to very large box (and this is what we will see when looking at it), but we
+will also recognize it by any of the other names we give - like crate or simply box as before.
+We could have given these aliases directly after the name in the create command, this is true for
+all creation commands - you can always tag on a list of ;-separated aliases to the name of your
+new object. If you had wanted to not change the name itself, but to only add aliases, you could have
+used the alias command.
+
We are currently carrying the box. Let’s drop it (there is also a short cut to create and drop in
+one go by using the /drop switch, for example create/dropbox).
+
drop box
+
+
+
Hey presto - there it is on the ground, in all its normality.
+
examine box
+
+
+
This will show some technical details about the box object. For now we will ignore what this
+information means.
+
Try to look at the box to see the (default) description.
+
look box
+You see nothing special.
+
+
+
The description you get is not very exciting. Let’s add some flavor.
+
describe box = This is a large and very heavy box.
+
+
+
If you try the get command we will pick up the box. So far so good, but if we really want this to
+be a large and heavy box, people should not be able to run off with it that easily. To prevent
+this we need to lock it down. This is done by assigning a Lock to it. Make sure the box was
+dropped in the room, then try this:
+
lock box = get:false()
+
+
+
Locks represent a rather big topic, but for now that will do what we want. This will lock
+the box so noone can lift it. The exception is superusers, they override all locks and will pick it
+up anyway. Make sure you are quelling your superuser powers and try to get the box now:
+
> get box
+You can't get that.
+
+
+
Think thís default error message looks dull? The get command looks for an Attribute
+named get_err_msg for returning a nicer error message (we just happen to know this, you would need
+to peek into the
+code for
+the get command to find out.). You set attributes using the set command:
+
set box/get_err_msg = It's way too heavy for you to lift.
+
+
+
Try to get it now and you should see a nicer error message echoed back to you. To see what this
+message string is in the future, you can use ‘examine.’
+
examine box/get_err_msg
+
+
+
Examine will return the value of attributes, including color codes. examinehere/desc would return
+the raw description of your current room (including color codes), so that you can copy-and-paste to
+set its description to something else.
+
You create new Commands (or modify existing ones) in Python outside the game. See the Adding
+Commands tutorial for help with creating your first own Command.
Scripts are powerful out-of-character objects useful for many “under the hood” things.
+One of their optional abilities is to do things on a timer. To try out a first script, let’s put one
+on ourselves. There is an example script in evennia/contrib/tutorial_examples/bodyfunctions.py
+that is called BodyFunctions. To add this to us we will use the script command:
(note that you don’t have to give the full path as long as you are pointing to a place inside the
+contrib directory, it’s one of the places Evennia looks for Scripts). Wait a while and you will
+notice yourself starting making random observations.
+
script self
+
+
+
This will show details about scripts on yourself (also examine works). You will see how long it is
+until it “fires” next. Don’t be alarmed if nothing happens when the countdown reaches zero - this
+particular script has a randomizer to determine if it will say something or not. So you will not see
+output every time it fires.
+
When you are tired of your character’s “insights”, kill the script with
You create your own scripts in Python, outside the game; the path you give to script is literally
+the Python path to your script file. The Scripts page explains more details.
If we get back to the box we made, there is only so much fun you can do with it at this point. It’s
+just a dumb generic object. If you renamed it to stone and changed its description noone would be
+the wiser. However, with the combined use of custom Typeclasses, Scripts
+and object-based Commands, you could expand it and other items to be as unique, complex
+and interactive as you want.
+
Let’s take an example. So far we have only created objects that use the default object typeclass
+named simply Object. Let’s create an object that is a little more interesting. Under
+evennia/contrib/tutorial_examples there is a module red_button.py. It contains the enigmatic
+RedButton typeclass.
We import the RedButton python class the same way you would import it in Python except Evennia makes
+sure to look inevennia/contrib/ so you don’t have to write the full path every time. There you go
+
+
one red button.
+
+
The RedButton is an example object intended to show off a few of Evennia’s features. You will find
+that the Typeclass and Commands controlling it are inside
+evennia/contrib/tutorial_examples/.
+
If you wait for a while (make sure you dropped it!) the button will blink invitingly. Why don’t you
+try to push it …? Surely a big red button is meant to be pushed. You know you want to.
The main command for shaping the game world is dig. For example, if you are standing in Limbo you
+can dig a route to your new house location like this:
+
dig house = large red door;door;in,to the outside;out
+
+
+
This will create a new room named ‘house’. Spaces at the start/end of names and aliases are ignored
+so you could put more air if you wanted. This call will directly create an exit from your current
+location named ‘large red door’ and a corresponding exit named ‘to the outside’ in the house room
+leading back to Limbo. We also define a few aliases to those exits, so people don’t have to write
+the full thing all the time.
+
If you wanted to use normal compass directions (north, west, southwest etc), you could do that with
+dig too. But Evennia also has a limited version of dig that helps for compass directions (and
+also up/down and in/out). It’s called tunnel:
+
tunnel sw = cliff
+
+
+
This will create a new room “cliff” with an exit “southwest” leading there and a path “northeast”
+leading back from the cliff to your current location.
+
You can create new exits from where you are using the open command:
+
open north;n = house
+
+
+
This opens an exit north (with an alias n) to the previously created room house.
+
If you have many rooms named house you will get a list of matches and have to select which one you
+want to link to. You can also give its database (#dbref) number, which is unique to every object.
+This can be found with the examine command or by looking at the latest constructions with
+objects.
+
Follow the north exit to your ‘house’ or teleport to it:
+
north
+
+
+
or:
+
teleport house
+
+
+
To manually open an exit back to Limbo (if you didn’t do so with the dig command):
Knowing the #dbref of the box (#8 in this example), you can grab the box and get it back here
+without actually yourself going to house first:
+
teleport #8 = here
+
+
+
(You can usually use here to refer to your current location. To refer to yourself you can use
+self or me). The box should now be back in Limbo with you.
+
We are getting tired of the box. Let’s destroy it.
+
destroy box
+
+
+
You can destroy many objects in one go by giving a comma-separated list of objects (or their
+#dbrefs, if they are not in the same location) to the command.
After this brief introduction to building you may be ready to see a more fleshed-out example.
+Evennia comes with a tutorial world for you to explore.
+
First you need to switch back to superuser by using the unquell command. Next, place yourself in
+Limbo and run the following command:
+
batchcommand tutorial_world.build
+
+
+
This will take a while (be patient and don’t re-run the command). You will see all the commands used
+to build the world scroll by as the world is built for you.
+
You will end up with a new exit from Limbo named tutorial. Apart from being a little solo-
+adventure in its own right, the tutorial world is a good source for learning Evennia building (and
+coding).
+
Read the batch
+file to see
+exactly how it’s built, step by step. See also more info about the tutorial world [here](Tutorial-
+World-Introduction).
This page was adapted from the article “Building a Giant Mech in Evennia” by Griatch, published in
+Imaginary Realities Volume 6, issue 1, 2014. The original article is no longer available online,
+this is a version adopted to be compatible with the latest Evennia.
Let us create a functioning giant mech using the Python MUD-creation system Evennia. Everyone likes
+a giant mech, right? Start in-game as a character with build privileges (or the superuser).
+
@create/drop Giant Mech ; mech
+
+
+
Boom. We created a Giant Mech Object and dropped it in the room. We also gave it an alias mech.
+Let’s describe it.
+
@desc mech = This is a huge mech. It has missiles and stuff.
+
+
+
Next we define who can “puppet” the mech object.
+
@lock mech = puppet:all()
+
+
+
This makes it so that everyone can control the mech. More mechs to the people! (Note that whereas
+Evennia’s default commands may look vaguely MUX-like, you can change the syntax to look like
+whatever interface style you prefer.)
+
Before we continue, let’s make a brief detour. Evennia is very flexible about its objects and even
+more flexible about using and adding commands to those objects. Here are some ground rules well
+worth remembering for the remainder of this article:
+
+
The Account represents the real person logging in and has no game-world existence.
+
Any Object can be puppeted by an Account (with proper permissions).
Any Object can be inside another (except if it creates a loop).
+
Any Object can store custom sets of commands on it. Those commands can:
+
+
be made available to the puppeteer (Account),
+
be made available to anyone in the same location as the Object, and
+
be made available to anyone “inside” the Object
+
Also Accounts can store commands on themselves. Account commands are always available unless
+commands on a puppeted Object explicitly override them.
+
+
+
+
In Evennia, using the @ic command will allow you to puppet a given Object (assuming you have
+puppet-access to do so). As mentioned above, the bog-standard Character class is in fact like any
+Object: it is auto-puppeted when logging in and just has a command set on it containing the normal
+in-game commands, like look, inventory, get and so on.
+
@ic mech
+
+
+
You just jumped out of your Character and are now the mech! If people look at you in-game, they
+will look at a mech. The problem at this point is that the mech Object has no commands of its own.
+The usual things like look, inventory and get sat on the Character object, remember? So at the
+moment the mech is not quite as cool as it could be.
+
@ic <Your old Character>
+
+
+
You just jumped back to puppeting your normal, mundane Character again. All is well.
+
+
(But, you ask, where did that @ic command come from, if the mech had no commands on it? The
+answer is that it came from the Account’s command set. This is important. Without the Account being
+the one with the @ic command, we would not have been able to get back out of our mech again.)
Let us make the mech a little more interesting. In our favorite text editor, we will create some new
+mech-suitable commands. In Evennia, commands are defined as Python classes.
+
# in a new file mygame/commands/mechcommands.py
+
+fromevenniaimportCommand
+
+classCmdShoot(Command):
+ """
+ Firing the mech’s gun
+
+ Usage:
+ shoot [target]
+
+ This will fire your mech’s main gun. If no
+ target is given, you will shoot in the air.
+ """
+ key="shoot"
+ aliases=["fire","fire!"]
+
+ deffunc(self):
+ "This actually does the shooting"
+
+ caller=self.caller
+ location=caller.location
+
+ ifnotself.args:
+ # no argument given to command - shoot in the air
+ message="BOOM! The mech fires its gun in the air!"
+ location.msg_contents(message)
+ return
+
+ # we have an argument, search for target
+ target=caller.search(self.args.strip())
+ iftarget:
+ message="BOOM! The mech fires its gun at %s"%target.key
+ location.msg_contents(message)
+
+classCmdLaunch(Command):
+ # make your own 'launch'-command here as an exercise!
+ # (it's very similar to the 'shoot' command above).
+
+
+
+
This is saved as a normal Python module (let’s call it mechcommands.py), in a place Evennia looks
+for such modules (mygame/commands/). This command will trigger when the player gives the command
+“shoot”, “fire,” or even “fire!” with an exclamation mark. The mech can shoot in the air or at a
+target if you give one. In a real game the gun would probably be given a chance to hit and give
+damage to the target, but this is enough for now.
+
We also make a second command for launching missiles (CmdLaunch). To save
+space we won’t describe it here; it looks the same except it returns a text
+about the missiles being fired and has different key and aliases. We leave
+that up to you to create as an exercise. You could have it print “WOOSH! The
+mech launches missiles against !”, for example.
+
Now we shove our commands into a command set. A Command Set (CmdSet) is a container
+holding any number of commands. The command set is what we will store on the mech.
+
# in the same file mygame/commands/mechcommands.py
+
+fromevenniaimportCmdSet
+fromevenniaimportdefault_cmds
+
+classMechCmdSet(CmdSet):
+ """
+ This allows mechs to do do mech stuff.
+ """
+ key="mechcmdset"
+
+ defat_cmdset_creation(self):
+ "Called once, when cmdset is first created"
+ self.add(CmdShoot())
+ self.add(CmdLaunch())
+
+
+
This simply groups all the commands we want. We add our new shoot/launch commands. Let’s head back
+into the game. For testing we will manually attach our new CmdSet to the mech.
This is a little Python snippet (run from the command line as an admin) that searches for the mech
+in our current location and attaches our new MechCmdSet to it. What we add is actually the Python
+path to our cmdset class. Evennia will import and initialize it behind the scenes.
+
@ic mech
+
+
+
We are back as the mech! Let’s do some shooting!
+
fire!
+BOOM! The mech fires its gun in the air!
+
+
+
There we go, one functioning mech. Try your own launch command and see that it works too. We can
+not only walk around as the mech — since the CharacterCmdSet is included in our MechCmdSet, the mech
+can also do everything a Character could do, like look around, pick up stuff, and have an inventory.
+We could now shoot the gun at a target or try the missile launch command. Once you have your own
+mech, what else do you need?
+
+
Note: You’ll find that the mech’s commands are available to you by just standing in the same
+location (not just by puppeting it). We’ll solve this with a lock in the next section.
What we’ve done so far is just to make a normal Object, describe it and put some commands on it.
+This is great for testing. The way we added it, the MechCmdSet will even go away if we reload the
+server. Now we want to make the mech an actual object “type” so we can create mechs without those
+extra steps. For this we need to create a new Typeclass.
+
A Typeclass is a near-normal Python class that stores its existence to the database
+behind the scenes. A Typeclass is created in a normal Python source file:
+
# in the new file mygame/typeclasses/mech.py
+
+fromtypeclasses.objectsimportObject
+fromcommands.mechcommandsimportMechCmdSet
+fromevenniaimportdefault_cmds
+
+classMech(Object):
+ """
+ This typeclass describes an armed Mech.
+ """
+ defat_object_creation(self):
+ "This is called only when object is first created"
+ self.cmdset.add_default(default_cmds.CharacterCmdSet)
+ self.cmdset.add(MechCmdSet,permanent=True)
+ self.locks.add("puppet:all();call:false()")
+ self.db.desc="This is a huge mech. It has missiles and stuff."
+
+
+
For convenience we include the full contents of the default CharacterCmdSet in there. This will
+make a Character’s normal commands available to the mech. We also add the mech-commands from before,
+making sure they are stored persistently in the database. The locks specify that anyone can puppet
+the meck and no-one can “call” the mech’s Commands from ‘outside’ it - you have to puppet it to be
+able to shoot.
+
That’s it. When Objects of this type are created, they will always start out with the mech’s command
+set and the correct lock. We set a default description, but you would probably change this with
+@desc to individualize your mechs as you build them.
+
Back in the game, just exit the old mech (@ic back to your old character) then do
+
@create/drop The Bigger Mech ; bigmech : mech.Mech
+
+
+
We create a new, bigger mech with an alias bigmech. Note how we give the python-path to our
+Typeclass at the end — this tells Evennia to create the new object based on that class (we don’t
+have to give the full path in our game dir typeclasses.mech.Mech because Evennia knows to look in
+the typeclasses folder already). A shining new mech will appear in the room! Just use
To expand on this you could add more commands to the mech and remove others. Maybe the mech
+shouldn’t work just like a Character after all. Maybe it makes loud noises every time it passes from
+room to room. Maybe it cannot pick up things without crushing them. Maybe it needs fuel, ammo and
+repairs. Maybe you’ll lock it down so it can only be puppeted by emo teenagers.
+
Having you puppet the mech-object directly is also just one way to implement a giant mech in
+Evennia.
+
For example, you could instead picture a mech as a “vehicle” that you “enter” as your normal
+Character (since any Object can move inside another). In that case the “insides” of the mech Object
+could be the “cockpit”. The cockpit would have the MechCommandSet stored on itself and all the
+shooting goodness would be made available to you only when you enter it.
+
And of course you could put more guns on it. And make it fly.
This contrib allows you to write custom and easy to use building menus. As the name implies, these
+menus are most useful for building things, that is, your builders might appreciate them, although
+you can use them for your players as well.
+
Building menus are somewhat similar to EvMenu although they don’t use the same system at all and
+are intended to make building easier. They replicate what other engines refer to as “building
+editors”, which allow to you to build in a menu instead of having to enter a lot of complex
+commands. Builders might appreciate this simplicity, and if the code that was used to create them
+is simple as well, coders could find this contrib useful.
Before diving in, there are some things to point out:
+
+
Building menus work on an object. This object will be edited by manipulations in the menu. So
+you can create a menu to add/edit a room, an exit, a character and so on.
+
Building menus are arranged in layers of choices. A choice gives access to an option or to a sub-
+menu. Choices are linked to commands (usually very short). For instance, in the example shown
+below, to edit the room key, after opening the building menu, you can type k. That will lead you
+to the key choice where you can enter a new key for the room. Then you can enter @ to leave this
+choice and go back to the entire menu. (All of this can be changed).
+
To open the menu, you will need something like a command. This contrib offers a basic command for
+demonstration, but we will override it in this example, using the same code with more flexibility.
Let’s begin by adding a new command. You could add or edit the following file (there’s no trick
+here, feel free to organize the code differently):
+
# file: commands/building.py
+fromevennia.contrib.building_menuimportBuildingMenu
+fromcommands.commandimportCommand
+
+classEditCmd(Command):
+
+ """
+ Editing command.
+
+ Usage:
+ @edit [object]
+
+ Open a building menu to edit the specified object. This menu allows to
+ specific information about this object.
+
+ Examples:
+ @edit here
+ @edit self
+ @edit #142
+
+ """
+
+ key="@edit"
+ locks="cmd:id(1) or perm(Builders)"
+ help_category="Building"
+
+ deffunc(self):
+ ifnotself.args.strip():
+ self.msg("|rYou should provide an argument to this function: the object to edit.|n")
+ return
+
+ obj=self.caller.search(self.args.strip(),global_search=True)
+ ifnotobj:
+ return
+
+ ifobj.typename=="Room":
+ Menu=RoomBuildingMenu
+ else:
+ self.msg("|rThe object {} cannot be
+edited.|n".format(obj.get_display_name(self.caller)))
+ return
+
+ menu=Menu(self.caller,obj)
+ menu.open()
+
+
+
This command is rather simple in itself:
+
+
It has a key @edit and a lock to only allow builders to use it.
+
In its func method, it begins by checking the arguments, returning an error if no argument is
+specified.
+
It then searches for the given argument. We search globally. The search method used in this
+way will return the found object or None. It will also send the error message to the caller if
+necessary.
+
Assuming we have found an object, we check the object typename. This will be used later when
+we want to display several building menus. For the time being, we only handle Room. If the
+caller specified something else, we’ll display an error.
+
Assuming this object is a Room, we have defined a Menu object containing the class of our
+building menu. We build this class (creating an instance), giving it the caller and the object to
+edit.
+
We then open the building menu, using the open method.
+
+
The end might sound a bit surprising at first glance. But the process is still very simple: we
+create an instance of our building menu and call its open method. Nothing more.
+
+
Where is our building menu?
+
+
If you go ahead and add this command and test it, you’ll get an error. We haven’t defined
+RoomBuildingMenu yet.
+
To add this command, edit commands/default_cmdsets.py. Import our command, adding an import line
+at the top of the file:
+
"""
+...
+"""
+
+fromevenniaimportdefault_cmds
+
+# The following line is to be added
+fromcommands.buildingimportEditCmd
+
+
+
And in the class below (CharacterCmdSet), add the last line of this code:
+
classCharacterCmdSet(default_cmds.CharacterCmdSet):
+ """
+ The `CharacterCmdSet` contains general in-game commands like `look`,
+ `get`, etc available on in-game Character objects. It is merged with
+ the `AccountCmdSet` when an Account puppets a Character.
+ """
+ key="DefaultCharacter"
+
+ defat_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ super().at_cmdset_creation()
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(EditCmd())
+
So far, we can’t use our building menu. Our @edit command will throw an error. We have to define
+the RoomBuildingMenu class. Open the commands/building.py file and add to the end of the file:
+
# ... at the end of commands/building.py
+# Our building menu
+
+classRoomBuildingMenu(BuildingMenu):
+
+ """
+ Building menu to edit a room.
+
+ For the time being, we have only one choice: key, to edit the room key.
+
+ """
+
+ definit(self,room):
+ self.add_choice("key","k",attr="key")
+
+
+
Save these changes, reload your game. You can now use the @edit command. Here’s what we get
+(notice that the commands we enter into the game are prefixed with >, though this prefix will
+probably not appear in your MUD client):
+
> look
+Limbo(#2)
+Welcome to your new Evennia-based game! Visit http://www.evennia.com if you need
+help, want to contribute, report issues or just join the community.
+As Account #1 you can create a demo/tutorial area with @batchcommand tutorial_world.build.
+
+> @edit here
+Building menu: Limbo
+
+ [K]ey: Limbo
+ [Q]uit the menu
+
+> q
+Closing the building menu.
+
+> @edit here
+Building menu: Limbo
+
+ [K]ey: Limbo
+ [Q]uit the menu
+
+> k
+-------------------------------------------------------------------------------
+key for Limbo(#2)
+
+You can change this value simply by entering it.
+
+Use @ to go back to the main menu.
+
+Current value: Limbo
+
+> A beautiful meadow
+-------------------------------------------------------------------------------
+
+key for A beautiful meadow(#2)
+
+You can change this value simply by entering it.
+
+Use @ to go back to the main menu.
+
+Current value: A beautiful meadow
+
+> @
+Building menu: A beautiful meadow
+
+ [K]ey: A beautiful meadow
+ [Q]uit the menu
+
+> q
+
+Closing the building menu.
+
+> look
+A beautiful meadow(#2)
+Welcome to your new Evennia-based game! Visit http://www.evennia.com if you need
+help, want to contribute, report issues or just join the community.
+As Account #1 you can create a demo/tutorial area with @batchcommand tutorial_world.build.
+
+
+
Before diving into the code, let’s examine what we have:
+
+
When we use the @edithere command, a building menu for this room appears.
+
This menu has two choices:
+
+
Enter k to edit the room key. You will go into a choice where you can simply type the key
+room key (the way we have done here). You can use @ to go back to the menu.
+
You can use q to quit the menu.
+
+
+
+
We then check, with the look command, that the menu has modified this room key. So by adding a
+class, with a method and a single line of code within, we’ve added a menu with two choices.
classRoomBuildingMenu(BuildingMenu):
+
+ """
+ Building menu to edit a room.
+
+ For the time being, we have only one choice: key, to edit the room key.
+
+ """
+
+ definit(self,room):
+ self.add_choice("key","k",attr="key")
+
+
+
+
We first create a class inheriting from BuildingMenu. This is usually the case when we want to
+create a building menu with this contrib.
+
In this class, we override the init method, which is called when the menu opens.
+
In this init method, we call add_choice. This takes several arguments, but we’ve defined only
+three here:
+
+
The choice name. This is mandatory and will be used by the building menu to know how to
+display this choice.
+
The command key to access this choice. We’ve given a simple "k". Menu commands usually are
+pretty short (that’s part of the reason building menus are appreciated by builders). You can also
+specify additional aliases, but we’ll see that later.
+
We’ve added a keyword argument, attr. This tells the building menu that when we are in this
+choice, the text we enter goes into this attribute name. It’s called attr, but it could be a room
+attribute or a typeclass persistent or non-persistent attribute (we’ll see other examples as well).
+
+
+
+
+
We’ve added the menu choice for key here, why is another menu choice defined for quit?
+
+
Our building menu creates a choice at the end of our choice list if it’s a top-level menu (sub-menus
+don’t have this feature). You can, however, override it to provide a different “quit” message or to
+perform some actions.
+
I encourage you to play with this code. As simple as it is, it offers some functionalities already.
This somewhat long section explains how to customize building menus. There are different ways
+depending on what you would like to achieve. We’ll go from specific to more advanced here.
In the previous example, we’ve used add_choice. This is one of three methods you can use to add
+choices. The other two are to handle more generic actions:
+
+
add_choice_edit: this is called to add a choice which points to the EvEditor. It is used to
+edit a description in most cases, although you could edit other things. We’ll see an example
+shortly. add_choice_edit uses most of the add_choice keyword arguments we’ll see, but usually
+we specify only two (sometimes three):
+
+
The choice title as usual.
+
The choice key (command key) as usual.
+
Optionally, the attribute of the object to edit, with the attr keyword argument. By
+default, attr contains db.desc. It means that this persistent data attribute will be edited by
+the EvEditor. You can change that to whatever you want though.
+
+
+
add_choice_quit: this allows to add a choice to quit the editor. Most advisable! If you don’t
+do it, the building menu will do it automatically, except if you really tell it not to. Again, you
+can specify the title and key of this menu. You can also call a function when this menu closes.
+
+
So here’s a more complete example (you can replace your RoomBuildingMenu class in
+commands/building.py to see it):
+
classRoomBuildingMenu(BuildingMenu):
+
+ """
+ Building menu to edit a room.
+ """
+
+ definit(self,room):
+ self.add_choice("key","k",attr="key")
+ self.add_choice_edit("description","d")
+ self.add_choice_quit("quit this editor","q")
+
+
+
So far, our building menu class is still thin… and yet we already have some interesting feature.
+See for yourself the following MUD client output (again, the commands are prefixed with > to
+distinguish them):
+
> @reload
+
+> @edit here
+Building menu: A beautiful meadow
+
+ [K]ey: A beautiful meadow
+ [D]escription:
+ Welcome to your new Evennia-based game! Visit http://www.evennia.com if you need
+help, want to contribute, report issues or just join the community.
+As Account #1 you can create a demo/tutorial area with @batchcommand tutorial_world.build.
+ [Q]uit this editor
+
+> d
+
+----------Line Editor [editor]----------------------------------------------------
+01| Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if you need
+02| help, want to contribute, report issues or just join the community.
+03| As Account #1 you can create a demo/tutorial area with |w@batchcommand tutorial_world.build|n.
+
+> :DD
+
+----------[l:03 w:034 c:0247]------------(:h for help)----------------------------
+Cleared 3 lines from buffer.
+
+> This is a beautiful meadow. But so beautiful I can't describe it.
+
+01| This is a beautiful meadow. But so beautiful I can't describe it.
+
+> :wq
+Building menu: A beautiful meadow
+
+ [K]ey: A beautiful meadow
+ [D]escription:
+ This is a beautiful meadow. But so beautiful I can't describe it.
+ [Q]uit this editor
+
+> q
+Closing the building menu.
+
+> look
+A beautiful meadow(#2)
+This is a beautiful meadow. But so beautiful I can't describe it.
+
+
+
So by using the d shortcut in our building menu, an EvEditor opens. You can use the EvEditor
+commands (like we did here, :DD to remove all, :wq to save and quit). When you quit the editor,
+the description is saved (here, in room.db.desc) and you go back to the building menu.
+
Notice that the choice to quit has changed too, which is due to our adding add_choice_quit. In
+most cases, you will probably not use this method, since the quit menu is added automatically.
add_choice and the two methods add_choice_edit and add_choice_quit take a lot of optional
+arguments to make customization easier. Some of these options might not apply to add_choice_edit
+or add_choice_quit however.
+
Below are the options of add_choice, specify them as arguments:
+
+
The first positional, mandatory argument is the choice title, as we have seen. This will
+influence how the choice appears in the menu.
+
The second positional, mandatory argument is the command key to access to this menu. It is best
+to use keyword arguments for the other arguments.
+
The aliases keyword argument can contain a list of aliases that can be used to access to this
+menu. For instance: add_choice(...,aliases=['t'])
+
The attr keyword argument contains the attribute to edit when this choice is selected. It’s a
+string, it has to be the name, from the object (specified in the menu constructor) to reach this
+attribute. For instance, a attr of "key" will try to find obj.key to read and write the
+attribute. You can specify more complex attribute names, for instance, attr="db.desc" to set the
+desc persistent attribute, or attr="ndb.something" so use a non-persistent data attribute on the
+object.
+
The text keyword argument is used to change the text that will be displayed when the menu choice
+is selected. Menu choices provide a default text that you can change. Since this is a long text,
+it’s useful to use multi-line strings (see an example below).
+
The glance keyword argument is used to specify how to display the current information while in
+the menu, when the choice hasn’t been opened. If you examine the previous examples, you will see
+that the current (key or db.desc) was shown in the menu, next to the command key. This is
+useful for seeing at a glance the current value (hence the name). Again, menu choices will provide
+a default glance if you don’t specify one.
+
The on_enter keyword argument allows to add a callback to use when the menu choice is opened.
+This is more advanced, but sometimes useful.
+
The on_nomatch keyword argument is called when, once in the menu, the caller enters some text
+that doesn’t match any command (including the @ command). By default, this will edit the
+specified attr.
+
The on_leave keyword argument allows to specify a callback used when the caller leaves the menu
+choice. This can be useful for cleanup as well.
+
+
These are a lot of possibilities, and most of the time you won’t need them all. Here is a short
+example using some of these arguments (again, replace the RoomBuildingMenu class in
+commands/building.py with the following code to see it working):
+
classRoomBuildingMenu(BuildingMenu):
+
+ """
+ Building menu to edit a room.
+
+ For the time being, we have only one choice: key, to edit the room key.
+
+ """
+
+ definit(self,room):
+ self.add_choice("title",key="t",attr="key",glance="{obj.key}",text="""
+ -------------------------------------------------------------------------------
+ Editing the title of {{obj.key}}(#{{obj.id}})
+
+ You can change the title simply by entering it.
+ Use |y{back}|n to go back to the main menu.
+
+ Current title: |c{{obj.key}}|n
+ """.format(back="|n or |y".join(self.keys_go_back)))
+ self.add_choice_edit("description","d")
+
The most surprising part is no doubt the text. We use the multi-line syntax (with """).
+Excessive spaces will be removed from the left for each line automatically. We specify some
+information between braces… sometimes using double braces. What might be a bit odd:
+
+
{back} is a direct format argument we’ll use (see the .format specifiers).
+
{{obj...}}referstotheobjectbeingedited.Weusetwobraces,because.format` will remove
+them.
+
+
In glance, we also use {obj.key} to indicate we want to show the room’s key.
The keyword arguments of add_choice are often strings (type str). But each of these arguments
+can also be a function. This allows for a lot of customization, since we define the callbacks that
+will be executed to achieve such and such an operation.
+
To demonstrate, we will try to add a new feature. Our building menu for rooms isn’t that bad, but
+it would be great to be able to edit exits too. So we can add a new menu choice below
+description… but how to actually edit exits? Exits are not just an attribute to set: exits are
+objects (of type Exit by default) which stands between two rooms (object of type Room). So how
+can we show that?
+
First let’s add a couple of exits in limbo, so we have something to work with:
+
@tunneln
+@tunnels
+
+
+
This should create two new rooms, exits leading to them from limbo and back to limbo.
+
>look
+Abeautifulmeadow(#2)
+Thisisabeautifulmeadow.ButsobeautifulIcan't describe it.
+Exits:north(#4) and south(#7)
+
+
+
We can access room exits with the exits property:
+
>@pyhere.exits
+[<Exit:north>,<Exit:south>]
+
+
+
So what we need is to display this list in our building menu… and to allow to edit it would be
+great. Perhaps even add new exits?
+
First of all, let’s write a function to display the glance on existing exits. Here’s the code,
+it’s explained below:
+
classRoomBuildingMenu(BuildingMenu):
+
+ """
+ Building menu to edit a room.
+
+ """
+
+ definit(self,room):
+ self.add_choice("title",key="t",attr="key",glance="{obj.key}",text="""
+ -------------------------------------------------------------------------------
+ Editing the title of {{obj.key}}(#{{obj.id}})
+
+ You can change the title simply by entering it.
+ Use |y{back}|n to go back to the main menu.
+
+ Current title: |c{{obj.key}}|n
+ """.format(back="|n or |y".join(self.keys_go_back)))
+ self.add_choice_edit("description","d")
+ self.add_choice("exits","e",glance=glance_exits,attr="exits")
+
+
+# Menu functions
+defglance_exits(room):
+ """Show the room exits."""
+ ifroom.exits:
+ glance=""
+ forexitinroom.exits:
+ glance+="\n |y{exit}|n".format(exit=exit.key)
+
+ returnglance
+
+ return"\n |gNo exit yet|n"
+
+
+
When the building menu opens, it displays each choice to the caller. A choice is displayed with its
+title (rendered a bit nicely to show the key as well) and the glance. In the case of the exits
+choice, the glance is a function, so the building menu calls this function giving it the object
+being edited (the room here). The function should return the text to see.
+
>@edithere
+Buildingmenu:Abeautifulmeadow
+
+ [T]itle:Abeautifulmeadow
+ [D]escription:
+ Thisisabeautifulmeadow.ButsobeautifulIcan't describe it.
+ [E]xits:
+ north
+ south
+ [Q]uitthemenu
+
+>q
+Closingtheeditor.
+
+
+
+
How do I know the parameters of the function to give?
+
+
The function you give can accept a lot of different parameters. This allows for a flexible approach
+but might seem complicated at first. Basically, your function can accept any parameter, and the
+building menu will send only the parameter based on their names. If your function defines an
+argument named caller for instance (like deffunc(caller): ), then the building menu knows that
+the first argument should contain the caller of the building menu. Here are the arguments, you
+don’t have to specify them (if you do, they need to have the same name):
+
+
menu: if your function defines an argument named menu, it will contain the building menu
+itself.
+
choice: if your function defines an argument named choice, it will contain the Choice object
+representing this menu choice.
+
string: if your function defines an argument named string, it will contain the user input to
+reach this menu choice. This is not very useful, except on nomatch callbacks which we’ll see
+later.
+
obj: if your function defines an argument named obj, it will contain the building menu edited
+object.
+
caller: if your function defines an argument named caller, it will contain the caller of the
+building menu.
+
Anything else: any other argument will contain the object being edited by the building menu.
+
+
So in our case:
+
defglance_exits(room):
+
+
+
The only argument we need is room. It’s not present in the list of possible arguments, so the
+editing object of the building menu (the room, here) is given.
+
+
Why is it useful to get the menu or choice object?
+
+
Most of the time, you will not need these arguments. In very rare cases, you will use them to get
+specific data (like the default attribute that was set). This tutorial will not elaborate on these
+possibilities. Just know that they exist.
+
We should also define a text callback, so that we can enter our menu to see the room exits. We’ll
+see how to edit them in the next section but this is a good opportunity to show a more complete
+callback. To see it in action, as usual, replace the class and functions in commands/building.py:
+
# Our building menu
+
+classRoomBuildingMenu(BuildingMenu):
+
+ """
+ Building menu to edit a room.
+
+ """
+
+ definit(self,room):
+ self.add_choice("title",key="t",attr="key",glance="{obj.key}",text="""
+ -------------------------------------------------------------------------------
+ Editing the title of {{obj.key}}(#{{obj.id}})
+
+ You can change the title simply by entering it.
+ Use |y{back}|n to go back to the main menu.
+
+ Current title: |c{{obj.key}}|n
+ """.format(back="|n or |y".join(self.keys_go_back)))
+ self.add_choice_edit("description","d")
+ self.add_choice("exits","e",glance=glance_exits,attr="exits",text=text_exits)
+
+
+# Menu functions
+defglance_exits(room):
+ """Show the room exits."""
+ ifroom.exits:
+ glance=""
+ forexitinroom.exits:
+ glance+="\n |y{exit}|n".format(exit=exit.key)
+
+ returnglance
+
+ return"\n |gNo exit yet|n"
+
+deftext_exits(caller,room):
+ """Show the room exits in the choice itself."""
+ text="-"*79
+ text+="\n\nRoom exits:"
+ text+="\n Use |y@c|n to create a new exit."
+ text+="\n\nExisting exits:"
+ ifroom.exits:
+ forexitinroom.exits:
+ text+="\n |y@e {exit}|n".format(exit=exit.key)
+ ifexit.aliases.all():
+ text+=" (|y{aliases}|n)".format(aliases="|n, |y".join(
+ aliasforaliasinexit.aliases.all()))
+ ifexit.destination:
+ text+=" toward {destination}".format(destination=exit.get_display_name(caller))
+ else:
+ text+="\n\n |gNo exit has yet been defined.|n"
+
+ returntext
+
+
+
Look at the second callback in particular. It takes an additional argument, the caller (remember,
+the argument names are important, their order is not relevant). This is useful for displaying
+destination of exits accurately. Here is a demonstration of this menu:
+
>@edithere
+Buildingmenu:Abeautifulmeadow
+
+ [T]itle:Abeautifulmeadow
+ [D]escription:
+ Thisisabeautifulmeadow.ButsobeautifulIcan't describe it.
+ [E]xits:
+ north
+ south
+ [Q]uitthemenu
+
+>e
+-------------------------------------------------------------------------------
+
+Roomexits:
+ Use@ctocreateanewexit.
+
+Existingexits:
+ @enorth(n)towardnorth(#4)
+ @esouth(s)towardsouth(#7)
+
+>@
+Buildingmenu:Abeautifulmeadow
+
+ [T]itle:Abeautifulmeadow
+ [D]escription:
+ Thisisabeautifulmeadow.ButsobeautifulIcan't describe it.
+ [E]xits:
+ north
+ south
+ [Q]uitthemenu
+
+>q
+Closingthebuildingmenu.
+
+
+
Using callbacks allows a great flexibility. We’ll now see how to handle sub-menus.
A menu is relatively flat: it has a root (where you see all the menu choices) and individual choices
+you can go to using the menu choice keys. Once in a choice you can type some input or go back to
+the root menu by entering the return command (usually @).
+
Why shouldn’t individual exits have their own menu though? Say, you edit an exit and can change its
+key, description or aliases… perhaps even destination? Why ever not? It would make building much
+easier!
+
The building menu system offers two ways to do that. The first is nested keys: nested keys allow to
+go beyond just one menu/choice, to have menus with more layers. Using them is quick but might feel
+a bit counter-intuitive at first. Another option is to create a different menu class and redirect
+from the first to the second. This option might require more lines but is more explicit and can be
+re-used for multiple menus. Adopt one of them depending of your taste.
So far, we’ve only used menu keys with one letter. We can add more, of course, but menu keys in
+their simple shape are just command keys. Press “e” to go to the “exits” choice.
+
But menu keys can be nested. Nested keys allow to add choices with sub-menus. For instance, type
+“e” to go to the “exits” choice, and then you can type “c” to open a menu to create a new exit, or
+“d” to open a menu to delete an exit. The first menu would have the “e.c” key (first e, then c),
+the second menu would have key as “e.d”.
+
That’s more advanced and, if the following code doesn’t sound very friendly to you, try the next
+section which provides a different approach of the same problem.
+
So we would like to edit exits. That is, you can type “e” to go into the choice of exits, then
+enter @e followed by the exit name to edit it… which will open another menu. In this sub-menu
+you could change the exit key or description.
This needs a bit of code and a bit of explanation. So here we go… the code first, the
+explanations next!
+
# ... from commands/building.py
+# Our building menu
+
+classRoomBuildingMenu(BuildingMenu):
+
+ """
+ Building menu to edit a room.
+
+ For the time being, we have only one choice: key, to edit the room key.
+
+ """
+
+ definit(self,room):
+ self.add_choice("title",key="t",attr="key",glance="{obj.key}",text="""
+ -------------------------------------------------------------------------------
+ Editing the title of {{obj.key}}(#{{obj.id}})
+
+ You can change the title simply by entering it.
+ Use |y{back}|n to go back to the main menu.
+
+ Current title: |c{{obj.key}}|n
+ """.format(back="|n or |y".join(self.keys_go_back)))
+ self.add_choice_edit("description","d")
+ self.add_choice("exits","e",glance=glance_exits,text=text_exits,
+on_nomatch=nomatch_exits)
+
+ # Exit sub-menu
+ self.add_choice("exit","e.*",text=text_single_exit,on_nomatch=nomatch_single_exit)
+
+
+
+# Menu functions
+defglance_exits(room):
+ """Show the room exits."""
+ ifroom.exits:
+ glance=""
+ forexitinroom.exits:
+ glance+="\n |y{exit}|n".format(exit=exit.key)
+
+ returnglance
+
+ return"\n |gNo exit yet|n"
+
+deftext_exits(caller,room):
+ """Show the room exits in the choice itself."""
+ text="-"*79
+ text+="\n\nRoom exits:"
+ text+="\n Use |y@c|n to create a new exit."
+ text+="\n\nExisting exits:"
+ ifroom.exits:
+ forexitinroom.exits:
+ text+="\n |y@e {exit}|n".format(exit=exit.key)
+ ifexit.aliases.all():
+ text+=" (|y{aliases}|n)".format(aliases="|n, |y".join(
+ aliasforaliasinexit.aliases.all()))
+ ifexit.destination:
+ text+=" toward {destination}".format(destination=exit.get_display_name(caller))
+ else:
+ text+="\n\n |gNo exit has yet been defined.|n"
+
+ returntext
+
+defnomatch_exits(menu,caller,room,string):
+ """
+ The user typed something in the list of exits. Maybe an exit name?
+ """
+ string=string[3:]
+ exit=caller.search(string,candidates=room.exits)
+ ifexitisNone:
+ return
+
+ # Open a sub-menu, using nested keys
+ caller.msg("Editing: {}".format(exit.key))
+ menu.move(exit)
+ returnFalse
+
+# Exit sub-menu
+deftext_single_exit(menu,caller):
+ """Show the text to edit single exits."""
+ exit=menu.keys[1]
+ ifexitisNone:
+ return""
+
+ return"""
+ Exit {exit}:
+
+ Enter the exit key to change it, or |y@|n to go back.
+
+ New exit key:
+ """.format(exit=exit.key)
+
+defnomatch_single_exit(menu,caller,room,string):
+ """The user entered something in the exit sub-menu. Replace the exit key."""
+ # exit is the second key element: keys should contain ['e', <Exit object>]
+ exit=menu.keys[1]
+ ifexitisNone:
+ caller.msg("|rCannot find the exit.|n")
+ menu.move(back=True)
+ returnFalse
+
+ exit.key=string
+ returnTrue
+
+
+
+
That’s a lot of code! And we only handle editing the exit key!
+
+
That’s why at some point you might want to write a real sub-menu, instead of using simple nested
+keys. But you might need both to build pretty menus too!
+
+
The first thing new is in our menu class. After creating a on_nomatch callback for the exits
+menu (that shouldn’t be a surprised), we need to add a nested key. We give this menu a key of
+"e.*". That’s a bit odd! “e” is our key to the exits menu, . is the separator to indicate a
+nested menu, and * means anything. So basically, we create a nested menu that is contains within
+the exits menu and anything. We’ll see what this “anything” is in practice.
+
The glance_exits and text_exits are basically the same.
+
The nomatch_exits is short but interesting. It’s called when we enter some text in the “exits”
+menu (that is, in the list of exits). We have said that the user should enter @e followed by the
+exit name to edit it. So in the nomatch_exits callbac, we check for that input. If the entered
+text begins by @e, we try to find the exit in the room. If we do…
+
We call the menu.move method. That’s where things get a bit complicated with nested menus: we
+need to use menu.move to change from layer to layer. Here, we are in the choice of exits (the
+exits menu, of key “e”). We need to go down one layer to edit an exit. So we call menu.move and
+give it an exit object. The menu system remembers what position the user is based on the keys she
+has entered: when the user opens the menu, there is no key. If she selects the exits choice, the
+menu key being “e”, the position of the user is ["e"] (a list with the menu keys). If we call
+menu.move, whatever we give to this method will be appended to the list of keys, so that the user
+position becomes ["e",<Exitobject>].
+
In the menu class, we have defined the menu “e.*”, meaning “the menu contained in the exits
+choice plus anything”. The “anything” here is an exit: we have called menu.move(exit), so the
+"e.*" menu choice is chosen.
+
In this menu, the text is set to a callback. There is also a on_nomatch callback that is
+called whenever the user enters some text. If so, we change the exit name.
+
+
Using menu.move like this is a bit confusing at first. Sometimes it’s useful. In this case, if
+we want a more complex menu for exits, it makes sense to use a real sub-menu, not nested keys like
+this. But sometimes, you will find yourself in a situation where you don’t need a full menu to
+handle a choice.
The best way to handle individual exits is to create two separate classes:
+
+
One for the room menu.
+
One for the individual exit menu.
+
+
The first one will have to redirect on the second. This might be more intuitive and flexible,
+depending on what you want to achieve. So let’s build two menus:
+
# Still in commands/building.py, replace the menu class and functions by...
+# Our building menus
+
+classRoomBuildingMenu(BuildingMenu):
+
+ """
+ Building menu to edit a room.
+ """
+
+ definit(self,room):
+ self.add_choice("title",key="t",attr="key",glance="{obj.key}",text="""
+ -------------------------------------------------------------------------------
+ Editing the title of {{obj.key}}(#{{obj.id}})
+
+ You can change the title simply by entering it.
+ Use |y{back}|n to go back to the main menu.
+
+ Current title: |c{{obj.key}}|n
+ """.format(back="|n or |y".join(self.keys_go_back)))
+ self.add_choice_edit("description","d")
+ self.add_choice("exits","e",glance=glance_exits,text=text_exits,
+on_nomatch=nomatch_exits)
+
+
+# Menu functions
+defglance_exits(room):
+ """Show the room exits."""
+ ifroom.exits:
+ glance=""
+ forexitinroom.exits:
+ glance+="\n |y{exit}|n".format(exit=exit.key)
+
+ returnglance
+
+ return"\n |gNo exit yet|n"
+
+deftext_exits(caller,room):
+ """Show the room exits in the choice itself."""
+ text="-"*79
+ text+="\n\nRoom exits:"
+ text+="\n Use |y@c|n to create a new exit."
+ text+="\n\nExisting exits:"
+ ifroom.exits:
+ forexitinroom.exits:
+ text+="\n |y@e {exit}|n".format(exit=exit.key)
+ ifexit.aliases.all():
+ text+=" (|y{aliases}|n)".format(aliases="|n, |y".join(
+ aliasforaliasinexit.aliases.all()))
+ ifexit.destination:
+ text+=" toward {destination}".format(destination=exit.get_display_name(caller))
+ else:
+ text+="\n\n |gNo exit has yet been defined.|n"
+
+ returntext
+
+defnomatch_exits(menu,caller,room,string):
+ """
+ The user typed something in the list of exits. Maybe an exit name?
+ """
+ string=string[3:]
+ exit=caller.search(string,candidates=room.exits)
+ ifexitisNone:
+ return
+
+ # Open a sub-menu, using nested keys
+ caller.msg("Editing: {}".format(exit.key))
+ menu.open_submenu("commands.building.ExitBuildingMenu",exit,parent_keys=["e"])
+ returnFalse
+
+classExitBuildingMenu(BuildingMenu):
+
+ """
+ Building menu to edit an exit.
+
+ """
+
+ definit(self,exit):
+ self.add_choice("key",key="k",attr="key",glance="{obj.key}")
+ self.add_choice_edit("description","d")
+
+
+
The code might be much easier to read. But before detailing it, let’s see how it behaves in the
+game:
+
> @edit here
+Building menu: A beautiful meadow
+
+ [T]itle: A beautiful meadow
+ [D]escription:
+ This is a beautiful meadow. But so beautiful I can't describe it.
+ [E]xits:
+ door
+ south
+ [Q]uit the menu
+
+> e
+-------------------------------------------------------------------------------
+
+Room exits:
+ Use @c to create a new exit.
+
+Existing exits:
+ @e door (n) toward door(#4)
+ @e south (s) toward south(#7)
+
+Editing: door
+
+> @e door
+Building menu: door
+
+ [K]ey: door
+ [D]escription:
+ None
+
+> k
+-------------------------------------------------------------------------------
+key for door(#4)
+
+You can change this value simply by entering it.
+
+Use @ to go back to the main menu.
+
+Current value: door
+
+> north
+
+-------------------------------------------------------------------------------
+key for north(#4)
+
+You can change this value simply by entering it.
+
+Use @ to go back to the main menu.
+
+Current value: north
+
+> @
+Building menu: north
+
+ [K]ey: north
+ [D]escription:
+ None
+
+> d
+----------Line Editor [editor]----------------------------------------------------
+01| None
+----------[l:01 w:001 c:0004]------------(:h for help)----------------------------
+
+> :DD
+Cleared 1 lines from buffer.
+
+> This is the northern exit. Cool huh?
+01| This is the northern exit. Cool huh?
+
+> :wq
+Building menu: north
+ [K]ey: north
+ [D]escription:
+ This is the northern exit. Cool huh?
+
+> @
+-------------------------------------------------------------------------------
+Room exits:
+ Use @c to create a new exit.
+
+Existing exits:
+ @e north (n) toward north(#4)
+ @e south (s) toward south(#7)
+
+> @
+Building menu: A beautiful meadow
+
+ [T]itle: A beautiful meadow
+ [D]escription:
+ This is a beautiful meadow. But so beautiful I can't describe it.
+ [E]xits:
+ north
+ south
+ [Q]uit the menu
+
+> q
+Closing the building menu.
+
+> look
+A beautiful meadow(#2)
+This is a beautiful meadow. But so beautiful I can't describe it.
+Exits: north(#4) and south(#7)
+> @py here.exits[0]
+>>> here.exits[0]
+north
+> @py here.exits[0].db.desc
+>>> here.exits[0].db.desc
+This is the northern exit. Cool huh?
+
+
+
Very simply, we created two menus and bridged them together. This needs much less callbacks. There
+is only one line in the nomatch_exits to add:
We have to call open_submenu on the menu object (which opens, as its name implies, a sub menu)
+with three arguments:
+
+
The path of the menu class to create. It’s the Python class leading to the menu (notice the
+dots).
+
The object that will be edited by the menu. Here, it’s our exit, so we give it to the sub-menu.
+
The keys of the parent to open when the sub-menu closes. Basically, when we’re in the root of the
+sub-menu and press @, we’ll open the parent menu, with the parent keys. So we specify ["e"],
+since the parent menus is the “exits” choice.
+
+
And that’s it. The new class will be automatically created. As you can see, we have to create a
+on_nomatch callback to open the sub-menu, but once opened, it automatically close whenever needed.
There are some options that can be set on any menu class. These options allow for greater
+customization. They are class attributes (see the example below), so just set them in the class
+body:
+
+
keys_go_back (default to ["@"]): the keys to use to go back in the menu hierarchy, from choice
+to root menu, from sub-menu to parent-menu. By default, only a @ is used. You can change this
+key for one menu or all of them. You can define multiple return commands if you want.
+
sep_keys (default "."): this is the separator for nested keys. There is no real need to
+redefine it except if you really need the dot as a key, and need nested keys in your menu.
+
joker_key (default to "*"): used for nested keys to indicate “any key”. Again, you shouldn’t
+need to change it unless you want to be able to use the @*@ in a command key, and also need nested
+keys in your menu.
+
min_shortcut (default to 1): although we didn’t see it here, one can create a menu choice
+without giving it a key. If so, the menu system will try to “guess” the key. This option allows to
+change the minimum length of any key for security reasons.
+
+
To set one of them just do so in your menu class(es):
Building menus mean to save you time and create a rich yet simple interface. But they can be
+complicated to learn and require reading the source code to find out how to do such and such a
+thing. This documentation, however long, is an attempt at describing this system, but chances are
+you’ll still have questions about it after reading it, especially if you try to push this system to
+a great extent. Do not hesitate to read the documentation of this contrib, it’s meant to be
+exhaustive but user-friendly.
This page gives an overview of the supported SQL databases as well as instructions on install:
+
+
SQLite3 (default)
+
PostgreSQL
+
MySQL / MariaDB
+
+
Since Evennia uses Django, most of our notes are based off of what we
+know from the community and their documentation. While the information below may be useful, you can
+always find the most up-to-date and “correct” information at Django’s Notes about supported
+Databases page.
SQLite3 is a light weight single-file database. It is our default database
+and Evennia will set this up for you automatically if you give no other options. SQLite stores the
+database in a single file (mygame/server/evennia.db3). This means it’s very easy to reset this
+database - just delete (or move) that evennia.db3 file and run evenniamigrate again! No server
+process is needed and the administrative overhead and resource consumption is tiny. It is also very
+fast since it’s run in-memory. For the vast majority of Evennia installs it will probably be all
+that’s ever needed.
+
SQLite will generally be much faster than MySQL/PostgreSQL but its performance comes with two
+drawbacks:
+
+
SQLite ignores length constraints by design; it is possible
+to store very large strings and numbers in fields that technically should not accept them. This is
+not something you will notice; your game will read and write them and function normally, but this
+can create some data migration problems requiring careful thought if you do need to change
+databases later.
+
SQLite can scale well to storage of millions of objects, but if you end up with a thundering herd
+of users trying to access your MUD and web site at the same time, or you find yourself writing long-
+running functions to update large numbers of objects on a live game, either will yield errors and
+interference. SQLite does not work reliably with multiple concurrent threads or processes accessing
+its records. This has to do with file-locking clashes of the database file. So for a production
+server making heavy use of process- or thread pools (or when using a third-party webserver like
+Apache), a proper database is a more appropriate choice.
This is installed and configured as part of Evennia. The database file is created as
+mygame/server/evennia.db3 when you run
+
evennia migrate
+
+
+
without changing any database options. An optional requirement is the sqlite3 client program -
+this is required if you want to inspect the database data manually. A shortcut for using it with the
+evennia database is evenniadbshell. Linux users should look for the sqlite3 package for their
+distro while Mac/Windows should get the sqlite-tools package from this
+page.
+
To inspect the default Evennia database (once it’s been created), go to your game dir and do
+
sqlite3 server/evennia.db3
+ # or
+ evennia dbshell
+
+
+
This will bring you into the sqlite command line. Use .help for instructions and .quit to exit.
+See here for a cheat-sheet of commands.
PostgreSQL is an open-source database engine, recommended by Django.
+While not as fast as SQLite for normal usage, it will scale better than SQLite, especially if your
+game has an very large database and/or extensive web presence through a separate server process.
First, install the posgresql server. Version 9.6 is tested with Evennia. Packages are readily
+available for all distributions. You need to also get the psql client (this is called postgresql-client on debian-derived systems). Windows/Mac users can find what they need on the postgresql
+download page. You should be setting up a password for your
+database-superuser (always called postgres) when you install.
+
For interaction with Evennia you need to also install psycopg2 to your Evennia install (pipinstallpsycopg2-binary in your virtualenv). This acts as the python bridge to the database server.
+
Next, start the postgres client:
+
psql -U postgres --password
+
+
+
+
:warning: Warning: With the --password argument, Postgres should prompt you for a password.
+If it won’t, replace that with -pyourpassword instead. Do not use the -p argument unless you
+have to since the resulting command, and your password, will be logged in the shell history.
+
+
This will open a console to the postgres service using the psql client.
+
On the psql command line:
+
CREATEUSERevenniaWITHPASSWORD'somepassword';
+CREATEDATABASEevennia;
+
+-- Postgres-specific optimizations
+-- https://docs.djangoproject.com/en/dev/ref/databases/#optimizing-postgresql-s-configuration
+ALTERROLEevenniaSETclient_encodingTO'utf8';
+ALTERROLEevenniaSETdefault_transaction_isolationTO'read committed';
+ALTERROLEevenniaSETtimezoneTO'UTC';
+
+GRANTALLPRIVILEGESONDATABASEevenniaTOevennia;
+-- Other useful commands:
+-- \l (list all databases and permissions)
+-- \q (exit)
+
We create a database user ‘evennia’ and a new database named evennia (you can call them whatever
+you want though). We then grant the ‘evennia’ user full privileges to the new database so it can
+read/write etc to it.
+If you in the future wanted to completely wipe the database, an easy way to do is to log in as the
+postgres superuser again, then do DROPDATABASEevennia;, then CREATE and GRANT steps above
+again to recreate the database and grant privileges.
MySQL is a commonly used proprietary database system, on par with
+PostgreSQL. There is an open-source alternative called MariaDB that mimics
+all functionality and command syntax of the former. So this section covers both.
First, install and setup MariaDB or MySQL for your specific server. Linux users should look for the
+mysql-server or mariadb-server packages for their respective distributions. Windows/Mac users
+will find what they need from the MySQL downloads or MariaDB
+downloads pages. You also need the respective database clients
+(mysql, mariadb-client), so you can setup the database itself. When you install the server you
+should usually be asked to set up the database root user and password.
+
You will finally also need a Python interface to allow Evennia to talk to the database. Django
+recommends the mysqlclient one. Install this into the evennia virtualenv with pipinstallmysqlclient.
+
Start the database client (this is named the same for both mysql and mariadb):
+
mysql -u root -p
+
+
+
You should get to enter your database root password (set this up when you installed the database
+server).
+
Inside the database client interface:
+
CREATEUSER'evennia'@'localhost'IDENTIFIEDBY'somepassword';
+CREATEDATABASEevennia;
+ALTERDATABASE`evennia`CHARACTERSETutf8;-- note that it's `evennia` with back-ticks, not
+quotes!
+GRANTALLPRIVILEGESONevennia.*TO'evennia'@'localhost';
+FLUSHPRIVILEGES;
+-- use 'exit' to quit client
+
Above we created a new local user and database (we called both ‘evennia’ here, you can name them
+what you prefer). We set the character set to utf8 to avoid an issue with prefix character length
+that can pop up on some installs otherwise. Next we grant the ‘evennia’ user all privileges on the
+evennia database and make sure the privileges are applied. Exiting the client brings us back to
+the normal terminal/console.
+
+
Note: If you are not using MySQL for anything else you might consider granting the ‘evennia’ user
+full privileges with GRANTALLPRIVILEGESON*.*TO'evennia'@'localhost';. If you do, it means
+you can use evenniadbshell later to connect to mysql, drop your database and re-create it as a
+way of easy reset. Without this extra privilege you will be able to drop the database but not re-
+create it without first switching to the database-root user.
To tell Evennia to use your new database you need to edit mygame/server/conf/settings.py (or
+secret_settings.py if you don’t want your db info passed around on git repositories).
+
+
Note: The Django documentation suggests using an external db.cnf or other external conf-
+formatted file. Evennia users have however found that this leads to problems (see e.g. issue
+#1184). To avoid trouble we recommend you simply put the configuration in
+your settings as below.
+
+
#
+ # MySQL Database Configuration
+ #
+ DATABASES={
+ 'default':{
+ 'ENGINE':'django.db.backends.mysql',
+ 'NAME':'evennia',
+ 'USER':'evennia',
+ 'PASSWORD':'somepassword',
+ 'HOST':'localhost',# or an IP Address that your DB is hosted on
+ 'PORT':'',# use default port
+ }
+ }
+
+
+
Change this to fit your database setup. Next, run:
+
evennia migrate
+
+
+
to populate your database. Should you ever want to inspect the database directly you can from now on
+also use
+
evennia dbshell
+
+
+
as a shortcut to get into the postgres command line for the right database and user.
+
With the database setup you should now be able to start start Evennia normally with your new
+database.
No testing has been performed with Oracle, but it is also supported through Django. There are
+community maintained drivers for MS SQL and possibly a few
+others. If you try other databases out, consider expanding this page with instructions.
This grid tries to gather info about different MU clients when used with Evennia.
+If you want to report a problem, update an entry or add a client, make a
+new documentation issue for it. Everyone’s encouraged to report their findings.
This FAQ page is for users to share their solutions to coding problems. Keep it brief and link to
+the docs if you can rather than too lengthy explanations. Don’t forget to check if an answer already
+exists before answering - maybe you can clarify that answer rather than to make a new Q&A section.
Q: The #dbref of a database object is ever-increasing. Evennia doesn’t allow you to change or
+reuse them. Will not a big/old game run out of dbref integers eventually?
+
A: No. For example, the default sqlite3 database’s max dbref is 2**64. If you created 10000
+objects every second every minute and every day of the year it would take ~60 million years for you
+to run out of dbref numbers. That’s a database of 140 TeraBytes, if every row was empty. If you are
+still using Evennia at that point and has this concern, get back to us and we can discuss adding
+dbref reuse then.
Q: How does one remove (not replace) e.g. the default getCommand from the
+Character Command Set?
+
A: Go to mygame/commands/default_cmdsets.py. Find the CharacterCmdSet class. It has one
+method named at_cmdset_creation. At the end of that method, add the following line:
+self.remove(default_cmds.CmdGet()). See the Adding Commands Tutorial
+for more info.
+
+
+
Preventing character from moving based on a condition¶
+
Q: How does one keep a character from using any exit, if they meet a certain condition? (I.E. in
+combat, immobilized, etc.)
+
A: The at_before_move hook is called by Evennia just before performing any move. If it returns
+False, the move is aborted. Let’s say we want to check for an Attributecantmove.
+Add the following code to the Character class:
+
defat_before_move(self,destination):
+ "Called just before trying to move"
+ ifself.db.cantmove:# replace with condition you want to test
+ self.msg("Something is preventing you from moving!")
+ returnFalse
+ returnTrue
+
+
+
+
+
Reference initiating object in an EvMenu command.¶
+
Q: An object has a Command on it starts up an EvMenu instance. How do I capture a reference to
+that object for use in the menu?
+
A: When an EvMenu is started, the menu object is stored as caller.ndb._menutree.
+This is a good place to store menu-specific things since it will clean itself up when the menu
+closes. When initiating the menu, any additional keywords you give will be available for you as
+properties on this menu object:
+
classMyObjectCommand(Command):
+ # A Command stored on an object (the object is always accessible from
+ # the Command as self.obj)
+ deffunc(self):
+ # add the object as the stored_obj menu property
+ EvMenu(caller,...,stored_obj=self.obj)
+
+
+
+
Inside the menu you can now access the object through caller.ndb._menutree.stored_obj.
Q: How do I add colors to the names of Evennia channels?
+
A: The Channel typeclass’ channel_prefix method decides what is shown at the beginning of a
+channel send. Edit mygame/typeclasses/channels.py (and then @reload):
+
# define our custom color names
+CHANNEL_COLORS={'public':'|015Public|n',
+ 'newbie':'|550N|n|551e|n|552w|n|553b|n|554i|n|555e|n',
+ 'staff':'|010S|n|020t|n|030a|n|040f|n|050f|n'}
+
+# Add to the Channel class
+ # ...
+ defchannel_prefix(self,msg,emit=False):
+ prefix_string=""
+ ifself.keyinCOLORS:
+ prefix_string="[%s] "%CHANNEL_COLORS.get(self.key.lower())
+ else:
+ prefix_string="[%s] "%self.key.capitalize()
+ returnprefix_string
+
+
+
Additional hint: To make colors easier to change from one place you could instead put the
+CHANNEL_COLORS dict in your settings file and import it as fromdjango.conf.settingsimportCHANNEL_COLORS.
Q: I want certain commands to turn off in a given room. They should still work normally for
+staff.
+
A: This is done using a custom cmdset on a room locked with the ‘call’ lock type. Only
+if this lock is passed will the commands on the room be made available to an object inside it. Here
+is an example of a room where certain commands are disabled for non-staff:
+
# in mygame/typeclasses/rooms.py
+
+fromevenniaimportdefault_commands,CmdSet
+
+classCmdBlocking(default_commands.MuxCommand):
+ # block commands give, get, inventory and drop
+ key="give"
+ aliases=["get","inventory","drop"]
+ deffunc(self):
+ self.caller.msg("You cannot do that in this room.")
+
+classBlockingCmdSet(CmdSet):
+ key="blocking_cmdset"
+ # default commands have prio 0
+ priority=1
+ defat_cmdset_creation(self):
+ self.add(CmdBlocking())
+
+classBlockingRoom(Room):
+ defat_object_creation(self):
+ self.cmdset.add(BlockingCmdSet,permanent=True)
+ # only share commands with players in the room that
+ # are NOT Builders or higher
+ self.locks.add("call:not perm(Builders)")
+
+
+
After @reload, make some BlockingRooms (or switch a room to it with @typeclass). Entering one
+will now replace the given commands for anyone that does not have the Builders or higher
+permission. Note that the ‘call’ lock is special in that even the superuser will be affected by it
+(otherwise superusers would always see other player’s cmdsets and a game would be unplayable for
+superusers).
Q: I want a command to be available only based on a condition. For example I want the “werewolf”
+command to only be available on a full moon, from midnight to three in-game time.
+
A: This is easiest accomplished by putting the “werewolf” command on the Character as normal,
+but to lock it with the “cmd” type lock. Only if the “cmd” lock type is passed will the
+command be available.
+
# in mygame/commands/command.py
+
+fromevenniaimportCommand
+
+classCmdWerewolf(Command):
+ key="werewolf"
+ # lock full moon, between 00:00 (midnight) and 03:00.
+ locks="cmd:is_full_moon(0, 3)"
+ deffunc(self):
+ # ...
+
# in mygame/server/conf/lockfuncs.py
+
+defis_full_moon(accessing_obj,accessed_obj,
+ starthour,endhour,*args,**kwargs):
+ # calculate if the moon is full here and
+ # if current game time is between starthour and endhour
+ # return True or False
+
+
+
+
After a @reload, the werewolf command will be available only at the right time, that is when the
+is_full_moon lock function returns True.
Q: I have a development server running Evennia. Can I have the server update its code-base when
+I reload?
+
A: Having a development server that pulls updated code whenever you reload it can be really
+useful if you have limited shell access to your server, or want to have it done automatically. If
+you have your project in a configured Git environment, it’s a matter of automatically calling gitpull when you reload. And that’s pretty straightforward:
+
In /server/conf/at_server_startstop.py:
+
importsubprocess
+
+# ... other hooks ...
+
+defat_server_reload_stop():
+ """
+ This is called only time the server stops before a reload.
+ """
+ print("Pulling from the game repository...")
+ process=subprocess.call(["git","pull"],shell=False)
+
+
+
That’s all. We call subprocess to execute a shell command (that code works on Windows and Linux,
+assuming the current directory is your game directory, which is probably the case when you run
+Evennia). call waits for the process to complete, because otherwise, Evennia would reload on
+partially-modified code, which would be problematic.
+
Now, when you enter @reload on your development server, the game repository is updated from the
+configured remote repository (Github, for instance). Your development cycle could resemble
+something like:
+
+
Coding on the local machine.
+
Testing modifications.
+
Committing once, twice or more (being sure the code is still working, unittests are pretty useful
+here).
+
When the time comes, login to the development server and run @reload.
+
+
The reloading might take one or two additional seconds, since Evennia will pull from your remote Git
+repository. But it will reload on it and you will have your modifications ready, without needing
+connecting to your server using SSH or something similar.
Q: How can I change the default exit messages to something like “XXX leaves east” or “XXX
+arrives from the west”?
+
A: the default exit messages are stored in two hooks, namely announce_move_from and
+announce_move_to, on the Character typeclass (if what you want to change is the message other
+characters will see when a character exits).
+
These two hooks provide some useful features to easily update the message to be displayed. They
+take both the default message and mapping as argument. You can easily call the parent hook with
+these information:
+
+
The message represents the string of characters sent to characters in the room when a character
+leaves.
+
The mapping is a dictionary containing additional mappings (you will probably not need it for
+simple customization).
+
+
It is advisable to look in the code of both
+hooks, and read the
+hooks’ documentation. The explanations on how to quickly update the message are shown below:
+
# In typeclasses/characters.py
+"""
+Characters
+
+"""
+fromevenniaimportDefaultCharacter
+
+classCharacter(DefaultCharacter):
+ """
+ The default character class.
+
+ ...
+ """
+
+ defannounce_move_from(self,destination,msg=None,mapping=None):
+ """
+ Called if the move is to be announced. This is
+ called while we are still standing in the old
+ location.
+
+ Args:
+ destination (Object): The place we are going to.
+ msg (str, optional): a replacement message.
+ mapping (dict, optional): additional mapping objects.
+
+ You can override this method and call its parent with a
+ message to simply change the default message. In the string,
+ you can use the following as mappings (between braces):
+ object: the object which is moving.
+ exit: the exit from which the object is moving (if found).
+ origin: the location of the object before the move.
+ destination: the location of the object after moving.
+
+ """
+ super().announce_move_from(destination,msg="{object} leaves {exit}.")
+
+ defannounce_move_to(self,source_location,msg=None,mapping=None):
+ """
+ Called after the move if the move was not quiet. At this point
+ we are standing in the new location.
+
+ Args:
+ source_location (Object): The place we came from
+ msg (str, optional): the replacement message if location.
+ mapping (dict, optional): additional mapping objects.
+
+ You can override this method and call its parent with a
+ message to simply change the default message. In the string,
+ you can use the following as mappings (between braces):
+ object: the object which is moving.
+ exit: the exit from which the object is moving (if found).
+ origin: the location of the object before the move.
+ destination: the location of the object after moving.
+
+ """
+ super().announce_move_to(source_location,msg="{object} arrives from the {exit}.")
+
+
+
We override both hooks, but call the parent hook to display a different message. If you read the
+provided docstrings, you will better understand why and how we use mappings (information between
+braces). You can provide additional mappings as well, if you want to set a verb to move, for
+instance, or other, extra information.
Do a @reload and all default commands will now use your new tweaked parent class. A copy of the
+MuxCommand class is also found commented-out in the mygame/commands/command.py file.
Q: If a user has already logged out of an Evennia account, their IP is no longer visible to
+staff that wants to ban-by-ip (instead of the user) with @ban/ip?
+
A: One approach is to write the IP from the last session onto the “account” account object.
Adding timestamp for login time and appending to a list to keep the last N login IP addresses and
+timestamps is possible, also. Additionally, if you don’t want the list to grow beyond a
+do_not_exceed length, conditionally pop a value after you’ve added it, if the length has grown too
+long.
+
NOTE: You’ll need to add importtime to generate the login timestamp.
+
defat_post_login(self,session=None,**kwargs):
+ super().at_post_login(session=session,**kwargs)
+ do_not_exceed=24# Keep the last two dozen entries
+ session=self.sessions.all()[-1]# Most recent session
+ ifnotself.db.lastsite:
+ self.db.lastsite=[]
+ self.db.lastsite.insert(0,(session.address,int(time.time())))
+ iflen(self.db.lastsite)>do_not_exceed:
+ self.db.lastsite.pop()
+
+
+
This only stores the data. You may want to interface the @ban command or make a menu-driven viewer
+for staff to browse the list and display how long ago the login occurred.
A: The reason for this is because certain non-latin characters are visually much wider than
+their len() suggests. There is little Evennia can (reliably) do about this. If you are using such
+characters, you need to make sure to use a suitable mono-spaced font where are width are equal. You
+can set this in your web client and need to recommend it for telnet-client users. See this
+discussion where some suitable fonts are suggested.
Evennia allows for a lot of freedom when designing your game - but to code efficiently you still
+need to adopt some best practices as well as find a good place to start to learn.
Evennia is developed using Python. Even if you are more of a designer than a coder, it is wise to
+learn how to read and understand basic Python code. If you are new to Python, or need a refresher,
+take a look at our two-part Python introduction.
When new to Evennia it can be hard to find things or figure out what is available. Evennia offers a
+special interactive python shell that allows you to experiment and try out things. It’s recommended
+to use ipython for this since the vanilla python prompt is very limited. Here
+are some simple commands to get started:
+
# [open a new console/terminal]
+# [activate your evennia virtualenv in this console/terminal]
+pip install ipython # [only needed the first time]
+cd mygame
+evennia shell
+
+
+
This will open an Evennia-aware python shell (using ipython). From within this shell, try
+
import evennia
+evennia.<TAB>
+
+
+
That is, enter evennia. and press the <TAB> key. This will show you all the resources made
+available at the top level of Evennia’s “flat API”. See the flat API page for more
+info on how to explore it efficiently.
+
You can complement your exploration by peeking at the sections of the much more detailed
+Developer Central. The Tutorials section also contains a growing collection
+of system- or implementation-specific help.
Evennia works by importing your own modules and running them as part of the server. Whereas Evennia
+should just gracefully tell you what errors it finds, it can nevertheless be a good idea for you to
+check your code for simple syntax errors before you load it into the running server. There are
+many python syntax checkers out there. A fast and easy one is
+pyflakes, a more verbose one is
+pylint. You can also check so that your code looks up to snuff using
+pep8. Even with a syntax checker you will not be able to catch
+every possible problem - some bugs or problems will only appear when you actually run the code. But
+using such a checker can be a good start to weed out the simple problems.
Before you start coding away at your dream game, take a look at our Game Planning
+page. It might hopefully help you avoid some common pitfalls and time sinks.
+
+
+
Code in your game folder, not in the evennia/ repository¶
+
As part of the Evennia setup you will create a game folder to host your game code. This is your
+home. You should never need to modify anything in the evennia library (anything you download
+from us, really). You import useful functionality from here and if you see code you like, copy&paste
+it out into your game folder and edit it there.
+
If you find that Evennia doesn’t support some functionality you need, make a
+Feature Request about it. Same goes for bugs. If you add features or fix bugs
+yourself, please consider Contributing your changes upstream!
Python is very good at reporting when and where things go wrong. A traceback shows everything you
+need to know about crashing code. The text can be pretty long, but you usually are only interested
+in the last bit, where it says what the error is and at which module and line number it happened -
+armed with this info you can resolve most problems.
+
Evennia will usually not show the full traceback in-game though. Instead the server outputs errors
+to the terminal/console from which you started Evennia in the first place. If you want more to show
+in-game you can add IN_GAME_ERRORS=True to your settings file. This will echo most (but not all)
+tracebacks both in-game as well as to the terminal/console. This is a potential security problem
+though, so don’t keep this active when your game goes into production.
+
+
A common confusing error is finding that objects in-game are suddenly of the type DefaultObject
+rather than your custom typeclass. This happens when you introduce a critical Syntax error to the
+module holding your custom class. Since such a module is not valid Python, Evennia can’t load it at
+all. Instead of crashing, Evennia will then print the full traceback to the terminal/console and
+temporarily fall back to the safe DefaultObject until you fix the problem and reload.
Some people find reading documentation extremely dull and shun it out of principle. That’s your
+call, but reading docs really does help you, promise! Evennia’s documentation is pretty thorough
+and knowing what is possible can often give you a lot of new cool game ideas. That said, if you
+can’t find the answer in the docs, don’t be shy to ask questions! The
+discussion group and the
+irc chat are also there for you.
Evennia comes with many utilities to help with common coding tasks. Most are accessible directly
+from the flat API, otherwise you can find them in the evennia/utils/ folder.
A common thing to do is to search for objects. There it’s easiest to use the search method defined
+on all objects. This will search for objects in the same location and inside the self object:
+
obj=self.search(objname)
+
+
+
The most common time one needs to do this is inside a command body. obj=self.caller.search(objname) will search inside the caller’s (typically, the character that typed
+the command) .contents (their “inventory”) and .location (their “room”).
+
Give the keyword global_search=True to extend search to encompass entire database. Aliases will
+also be matched by this search. You will find multiple examples of this functionality in the default
+command set.
+
If you need to search for objects in a code module you can use the functions in
+evennia.utils.search. You can access these as shortcuts evennia.search_*.
Apart from the in-game build commands (@create etc), you can also build all of Evennia’s game
+entities directly in code (for example when defining new create commands).
Normally you can use Python print statements to see output to the terminal/log. The print
+statement should only be used for debugging though. For producion output, use the logger which
+will create proper logs either to terminal or to file.
+
fromevenniaimportlogger
+ #
+ logger.log_err("This is an Error!")
+ logger.log_warn("This is a Warning!")
+ logger.log_info("This is normal information")
+ logger.log_dep("This feature is deprecated")
+
+
+
There is a special log-message type, log_trace() that is intended to be called from inside a
+traceback - this can be very useful for relaying the traceback message back to log without having it
+kill the server.
+
try:
+ # [some code that may fail...]
+ exceptException:
+ logger.log_trace("This text will show beneath the traceback itself.")
+
+
+
The log_file logger, finally, is a very useful logger for outputting arbitrary log messages. This
+is a heavily optimized asynchronous log mechanism using
+threads to avoid overhead. You should be
+able to use it for very heavy custom logging without fearing disk-write delays.
+
logger.log_file(message,filename="mylog.log")
+
+
+
If not an absolute path is given, the log file will appear in the mygame/server/logs/ directory.
+If the file already exists, it will be appended to. Timestamps on the same format as the normal
+Evennia logs will be automatically added to each entry. If a filename is not specified, output will
+be written to a file game/logs/game.log.
Evennia tracks the current server time. You can access this time via the evennia.gametime
+shortcut:
+
fromevenniaimportgametime
+
+# all the functions below return times in seconds).
+
+# total running time of the server
+runtime=gametime.runtime()
+# time since latest hard reboot (not including reloads)
+uptime=gametime.uptime()
+# server epoch (its start time)
+server_epoch=gametime.server_epoch()
+
+# in-game epoch (this can be set by `settings.TIME_GAME_EPOCH`.
+# If not, the server epoch is used.
+game_epoch=gametime.game_epoch()
+# in-game time passed since time started running
+gametime=gametime.gametime()
+# in-game time plus game epoch (i.e. the current in-game
+# time stamp)
+gametime=gametime.gametime(absolute=True)
+# reset the game time (back to game epoch)
+gametime.reset_gametime()
+
+
+
+
The setting TIME_FACTOR determines how fast/slow in-game time runs compared to the real world. The
+setting TIME_GAME_EPOCH sets the starting game epoch (in seconds). The functions from the
+gametime module all return their times in seconds. You can convert this to whatever units of time
+you desire for your game. You can use the @time command to view the server time info.
+
You can also schedule things to happen at specific in-game times using the
+gametime.schedule function:
This function takes a number of seconds as input (e.g. from the gametime module above) and
+converts it to a nice text output in days, hours etc. It’s useful when you want to show how old
+something is. It converts to four different styles of output using the style keyword:
+
+
style 0 - 5d:45m:12s (standard colon output)
+
style 1 - 5d (shows only the longest time unit)
+
style 2 - 5days,45minutes (full format, ignores seconds)
+
style 3 - 5days,45minutes,12seconds (full format, with seconds)
fromevenniaimportutils
+
+def_callback(obj,text):
+ obj.msg(text)
+
+# wait 10 seconds before sending "Echo!" to obj (which we assume is defined)
+deferred=utils.delay(10,_callback,obj,"Echo!",persistent=False)
+
+# code here will run immediately, not waiting for the delay to fire!
+
+
+
+
This creates an asynchronous delayed call. It will fire the given callback function after the given
+number of seconds. This is a very light wrapper over a Twisted
+Deferred. Normally this is run
+non-persistently, which means that if the server is @reloaded before the delay is over, the
+callback will never run (the server forgets it). If setting persistent to True, the delay will be
+stored in the database and survive a @reload - but for this to work it is susceptible to the same
+limitations incurred when saving to an Attribute.
+
The deferred return object can usually be ignored, but calling its .cancel() method will abort
+the delay prematurely.
+
utils.delay is the lightest form of delayed call in Evennia. For other way to create time-bound
+tasks, see the TickerHandler and Scripts.
+
+
Note that many delayed effects can be achieved without any need for an active timer. For example
+if you have a trait that should recover a point every 5 seconds you might just need its value when
+it’s needed, but checking the current time and calculating on the fly what value it should have.
This useful function takes two arguments - an object to check and a parent. It returns True if
+object inherits from parent at any distance (as opposed to Python’s in-built is_instance() that
+will only catch immediate dependence). This function also accepts as input any combination of
+classes, instances or python-paths-to-classes.
+
Note that Python code should usually work with duck
+typing. But in Evennia’s case it can sometimes be useful
+to check if an object inherits from a given Typeclass as a way of identification. Say
+for example that we have a typeclass Animal. This has a subclass Felines which in turn has a
+subclass HouseCat. Maybe there are a bunch of other animal types too, like horses and dogs. Using
+inherits_from will allow you to check for all animals in one go:
+
fromevenniaimportutils
+ if(utils.inherits_from(obj,"typeclasses.objects.animals.Animal"):
+ obj.msg("The bouncer stops you in the door. He says: 'No talking animals allowed.'")
+
In a text game, you are naturally doing a lot of work shuffling text back and forth. Here is a non-
+complete selection of text utilities found in evennia/utils/utils.py (shortcut evennia.utils).
+If nothing else it can be good to look here before starting to develop a solution of your own.
This function will crop a very long line, adding a suffix to show the line actually continues. This
+can be useful in listings when showing multiple lines would mess up things.
+
intxt="This is a long text that we want to crop."
+ outtxt=crop(intxt,width=19,suffix="[...]")
+ # outtxt is now "This is a long text[...]"
+
This solves what may at first glance appear to be a trivial problem with text - removing
+indentations. It is used to shift entire paragraphs to the left, without disturbing any further
+formatting they may have. A common case for this is when using Python triple-quoted strings in code
+
+
they will retain whichever indentation they have in the code, and to make easily-readable source
+code one usually don’t want to shift the string to the left edge.
+
+
#python code is entered at a given indentation
+ intxt="""
+ This is an example text that will end
+ up with a lot of whitespace on the left.
+ It also has indentations of
+ its own."""
+ outtxt=dedent(intxt)
+ # outtxt will now retain all internal indentation
+ # but be shifted all the way to the left.
+
+
+
Normally you do the dedent in the display code (this is for example how the help system homogenizes
+help entries).
Evennia supplies two utility functions for converting text to the correct
+encodings. to_str() and to_bytes(). Unless you are adding a custom protocol and
+need to send byte-data over the wire, to_str is the only one you’ll need.
+
The difference from Python’s in-built str() and bytes() operators are that
+the Evennia ones makes use of the ENCODINGS setting and will try very hard to
+never raise a traceback but instead echo errors through logging. See
+here for more info.
The EvTable class (evennia/utils/evtable.py) can be used
+to create correctly formatted text tables. There is also
+EvForm (evennia/utils/evform.py). This reads a fixed-format
+text template from a file in order to create any level of sophisticated ascii layout. Both evtable
+and evform have lots of options and inputs so see the header of each module for help.
+
The third-party PrettyTable module is also included in
+Evennia. PrettyTable is considered deprecated in favor of EvTable since PrettyTable cannot handle
+ANSI colour. PrettyTable can be found in evennia/utils/prettytable/. See its homepage above for
+instructions.
Some types of games want to limit how often a command can be run. If a
+character casts the spell Firestorm, you might not want them to spam that
+command over and over. Or in an advanced combat system, a massive swing may
+offer a chance of lots of damage at the cost of not being able to re-do it for
+a while. Such effects are called cooldowns.
+
This page exemplifies a very resource-efficient way to do cooldowns. A more
+‘active’ way is to use asynchronous delays as in the command duration
+tutorial, the two might be useful to
+combine if you want to echo some message to the user after the cooldown ends.
This little recipe will limit how often a particular command can be run. Since
+Commands are class instances, and those are cached in memory, a command
+instance will remember things you store on it. So just store the current time
+of execution! Next time the command is run, it just needs to check if it has
+that time stored, and compare it with the current time to see if a desired
+delay has passed.
+
importtime
+fromevenniaimportdefault_cmds
+
+classCmdSpellFirestorm(default_cmds.MuxCommand):
+ """
+ Spell - Firestorm
+
+ Usage:
+ cast firestorm <target>
+
+ This will unleash a storm of flame. You can only release one
+ firestorm every five minutes (assuming you have the mana).
+ """
+ key="cast firestorm"
+ locks="cmd:isFireMage()"
+
+ deffunc(self):
+ "Implement the spell"
+
+ # check cooldown (5 minute cooldown)
+ now=time.time()
+ ifhasattr(self,"lastcast")and \
+ now-self.lastcast<5*60:
+ message="You cannot cast this spell again yet."
+ self.caller.msg(message)
+ return
+
+ #[the spell effect is implemented]
+
+ # if the spell was successfully cast, store the casting time
+ self.lastcast=now
+
+
+
We just check the lastcast flag, and update it if everything works out.
+Simple and very effective since everything is just stored in memory. The
+drawback of this simple scheme is that it’s non-persistent. If you do
+@reload, the cache is cleaned and all such ongoing cooldowns will be
+forgotten. It is also limited only to this one command, other commands cannot
+(easily) check for this value.
This is essentially the same mechanism as the simple one above, except we use
+the database to store the information which means the cooldown will survive a
+server reload/reboot. Since commands themselves have no representation in the
+database, you need to use the caster for the storage.
+
# inside the func() of CmdSpellFirestorm as above
+
+ # check cooldown (5 minute cooldown)
+
+ now=time.time()
+ lastcast=self.caller.db.firestorm_lastcast
+
+ iflastcastandnow-lastcast<5*60:
+ message="You need to wait before casting this spell again."
+ self.caller.msg(message)
+ return
+
+ #[the spell effect is implemented]
+
+ # if the spell was successfully cast, store the casting time
+ self.caller.db.firestorm_lastcast=now
+
+
+
Since we are storing as an Attribute, we need to identify the
+variable as firestorm_lastcast so we are sure we get the right one (we’ll
+likely have other skills with cooldowns after all). But this method of
+using cooldowns also has the advantage of working between commands - you can
+for example let all fire-related spells check the same cooldown to make sure
+the casting of Firestorm blocks all fire-related spells for a while. Or, in
+the case of taking that big swing with the sword, this could now block all
+other types of attacks for a while before the warrior can recover.
Before reading this tutorial, if you haven’t done so already, you might want to
+read the documentation on commands to get a basic understanding of
+how commands work in Evennia.
+
In some types of games a command should not start and finish immediately.
+Loading a crossbow might take a bit of time to do - time you don’t have when
+the enemy comes rushing at you. Crafting that armour will not be immediate
+either. For some types of games the very act of moving or changing pose all
+comes with a certain time associated with it.
Evennia allows a shortcut in syntax to create simple pauses in commands. This
+syntax uses the yield keyword. The yield keyword is used in Python to
+create generators, although you don’t need to know what generators are to use
+this syntax. A short example will probably make it clear:
+
classCmdTest(Command):
+
+ """
+ A test command just to test waiting.
+
+ Usage:
+ test
+
+ """
+
+ key="test"
+ locks="cmd:all()"
+
+ deffunc(self):
+ self.msg("Before ten seconds...")
+ yield10
+ self.msg("Afterwards.")
+
+
+
+
Important: The yield functionality will only work in the func method of
+Commands. It only works because Evennia has especially
+catered for it in Commands. If you want the same functionality elsewhere you
+must use the interactive decorator.
+
+
The important line is the yield10. It tells Evennia to “pause” the command
+and to wait for 10 seconds to execute the rest. If you add this command and
+run it, you’ll see the first message, then, after a pause of ten seconds, the
+next message. You can use yield several times in your command.
+
This syntax will not “freeze” all commands. While the command is “pausing”,
+you can execute other commands (or even call the same command again). And
+other players aren’t frozen either.
+
+
Note: this will not save anything in the database. If you reload the game
+while a command is “paused”, it will not resume after the server has
+reloaded.
The yield syntax is easy to read, easy to understand, easy to use. But it’s not that flexible if
+you want more advanced options. Learning to use alternatives might be much worth it in the end.
+
Below is a simple command example for adding a duration for a command to finish.
+
fromevenniaimportdefault_cmds,utils
+
+classCmdEcho(default_cmds.MuxCommand):
+ """
+ wait for an echo
+
+ Usage:
+ echo <string>
+
+ Calls and waits for an echo
+ """
+ key="echo"
+ locks="cmd:all()"
+
+ deffunc(self):
+ """
+ This is called at the initial shout.
+ """
+ self.caller.msg("You shout '%s' and wait for an echo ..."%self.args)
+ # this waits non-blocking for 10 seconds, then calls self.echo
+ utils.delay(10,self.echo)# call echo after 10 seconds
+
+ defecho(self):
+ "Called after 10 seconds."
+ shout=self.args
+ string="You hear an echo: %s ... %s ... %s"
+ string=string%(shout.upper(),shout.capitalize(),shout.lower())
+ self.caller.msg(string)
+
+
+
Import this new echo command into the default command set and reload the server. You will find that
+it will take 10 seconds before you see your shout coming back. You will also find that this is a
+non-blocking effect; you can issue other commands in the interim and the game will go on as usual.
+The echo will come back to you in its own time.
utils.delay(timedelay,callback,persistent=False,*args,**kwargs) is a useful function. It will
+wait timedelay seconds, then call the callback function, optionally passing to it the arguments
+provided to utils.delay by way of *args and/or **kwargs`.
+
+
Note: The callback argument should be provided with a python path to the desired function, for
+instance my_object.my_function instead of my_object.my_function(). Otherwise my_function would
+get called and run immediately upon attempting to pass it to the delay function.
+If you want to provide arguments for utils.delay to use, when calling your callback function, you
+have to do it separatly, for instance using the utils.delay *args and/or **kwargs, as mentioned
+above.
Looking at it you might think that utils.delay(10,callback) in the code above is just an
+alternative to some more familiar thing like time.sleep(10). This is not the case. If you do
+time.sleep(10) you will in fact freeze the entire server for ten seconds! The utils.delay()is
+a thin wrapper around a Twisted
+Deferred that will delay
+execution until 10 seconds have passed, but will do so asynchronously, without bothering anyone else
+(not even you - you can continue to do stuff normally while it waits to continue).
+
The point to remember here is that the delay() call will not “pause” at that point when it is
+called (the way yield does in the previous section). The lines after the delay() call will
+actually execute right away. What you must do is to tell it which function to call after the time
+has passed (its “callback”). This may sound strange at first, but it is normal practice in
+asynchronous systems. You can also link such calls together as seen below:
+
fromevenniaimportdefault_cmds,utils
+
+classCmdEcho(default_cmds.MuxCommand):
+ """
+ waits for an echo
+
+ Usage:
+ echo <string>
+
+ Calls and waits for an echo
+ """
+ key="echo"
+ locks="cmd:all()"
+
+ deffunc(self):
+ "This sets off a chain of delayed calls"
+ self.caller.msg("You shout '%s', waiting for an echo ..."%self.args)
+
+ # wait 2 seconds before calling self.echo1
+ utils.delay(2,self.echo1)
+
+ # callback chain, started above
+ defecho1(self):
+ "First echo"
+ self.caller.msg("... %s"%self.args.upper())
+ # wait 2 seconds for the next one
+ utils.delay(2,self.echo2)
+
+ defecho2(self):
+ "Second echo"
+ self.caller.msg("... %s"%self.args.capitalize())
+ # wait another 2 seconds
+ utils.delay(2,callback=self.echo3)
+
+ defecho3(self):
+ "Last echo"
+ self.caller.msg("... %s ..."%self.args.lower())
+
+
+
The above version will have the echoes arrive one after another, each separated by a two second
+delay.
As mentioned, a great thing about the delay introduced by yield or utils.delay() is that it does
+not block. It just goes on in the background and you are free to play normally in the interim. In
+some cases this is not what you want however. Some commands should simply “block” other commands
+while they are running. If you are in the process of crafting a helmet you shouldn’t be able to also
+start crafting a shield at the same time, or if you just did a huge power-swing with your weapon you
+should not be able to do it again immediately.
+
The simplest way of implementing blocking is to use the technique covered in the Command
+Cooldown tutorial. In that tutorial we implemented cooldowns by having the
+Command store the current time. Next time the Command was called, we compared the current time to
+the stored time to determine if enough time had passed for a renewed use. This is a very
+efficient, reliable and passive solution. The drawback is that there is nothing to tell the Player
+when enough time has passed unless they keep trying.
+
Here is an example where we will use utils.delay to tell the player when the cooldown has passed:
+
fromevenniaimportutils,default_cmds
+
+classCmdBigSwing(default_cmds.MuxCommand):
+ """
+ swing your weapon in a big way
+
+ Usage:
+ swing <target>
+
+ Makes a mighty swing. Doing so will make you vulnerable
+ to counter-attacks before you can recover.
+ """
+ key="bigswing"
+ locks="cmd:all()"
+
+ deffunc(self):
+ "Makes the swing"
+
+ ifself.caller.ndb.off_balance:
+ # we are still off-balance.
+ self.caller.msg("You are off balance and need time to recover!")
+ return
+
+ # [attack/hit code goes here ...]
+ self.caller.msg("You swing big! You are off balance now.")
+
+ # set the off-balance flag
+ self.caller.ndb.off_balance=True
+
+ # wait 8 seconds before we can recover. During this time
+ # we won't be able to swing again due to the check at the top.
+ utils.delay(8,self.recover)
+
+ defrecover(self):
+ "This will be called after 8 secs"
+ delself.caller.ndb.off_balance
+ self.caller.msg("You regain your balance.")
+
+
+
Note how, after the cooldown, the user will get a message telling them they are now ready for
+another swing.
+
By storing the off_balance flag on the character (rather than on, say, the Command instance
+itself) it can be accessed by other Commands too. Other attacks may also not work when you are off
+balance. You could also have an enemy Command check your off_balance status to gain bonuses, to
+take another example.
One can imagine that you will want to abort a long-running command before it has a time to finish.
+If you are in the middle of crafting your armor you will probably want to stop doing that when a
+monster enters your smithy.
+
You can implement this in the same way as you do the “blocking” command above, just in reverse.
+Below is an example of a crafting command that can be aborted by starting a fight:
+
fromevenniaimportutils,default_cmds
+
+classCmdCraftArmour(default_cmds.MuxCommand):
+ """
+ Craft armour
+
+ Usage:
+ craft <name of armour>
+
+ This will craft a suit of armour, assuming you
+ have all the components and tools. Doing some
+ other action (such as attacking someone) will
+ abort the crafting process.
+ """
+ key="craft"
+ locks="cmd:all()"
+
+ deffunc(self):
+ "starts crafting"
+
+ ifself.caller.ndb.is_crafting:
+ self.caller.msg("You are already crafting!")
+ return
+ ifself._is_fighting():
+ self.caller.msg("You can't start to craft "
+ "in the middle of a fight!")
+ return
+
+ # [Crafting code, checking of components, skills etc]
+
+ # Start crafting
+ self.caller.ndb.is_crafting=True
+ self.caller.msg("You start crafting ...")
+ utils.delay(60,self.step1)
+
+ def_is_fighting(self):
+ "checks if we are in a fight."
+ ifself.caller.ndb.is_fighting:
+ delself.caller.ndb.is_crafting
+ returnTrue
+
+ defstep1(self):
+ "first step of armour construction"
+ ifself._is_fighting():
+ return
+ self.msg("You create the first part of the armour.")
+ utils.delay(60,callback=self.step2)
+
+ defstep2(self):
+ "second step of armour construction"
+ ifself._is_fighting():
+ return
+ self.msg("You create the second part of the armour.")
+ utils.delay(60,step3)
+
+ defstep3(self):
+ "last step of armour construction"
+ ifself._is_fighting():
+ return
+
+ # [code for creating the armour object etc]
+
+ delself.caller.ndb.is_crafting
+ self.msg("You finalize your armour.")
+
+
+# example of a command that aborts crafting
+
+classCmdAttack(default_cmds.MuxCommand):
+ """
+ attack someone
+
+ Usage:
+ attack <target>
+
+ Try to cause harm to someone. This will abort
+ eventual crafting you may be currently doing.
+ """
+ key="attack"
+ aliases=["hit","stab"]
+ locks="cmd:all()"
+
+ deffunc(self):
+ "Implements the command"
+
+ self.caller.ndb.is_fighting=True
+
+ # [...]
+
+
+
The above code creates a delayed crafting command that will gradually create the armour. If the
+attack command is issued during this process it will set a flag that causes the crafting to be
+quietly canceled next time it tries to update.
In the latter examples above we used .ndb storage. This is fast and easy but it will reset all
+cooldowns/blocks/crafting etc if you reload the server. If you don’t want that you can replace
+.ndb with .db. But even this won’t help because the yield keyword is not persisent and nor is
+the use of delay shown above. To resolve this you can use delay with the persistent=True
+keyword. But wait! Making something persistent will add some extra complications, because now you
+must make sure Evennia can properly store things to the database.
+
Here is the original echo-command reworked to function with persistence:
+
fromevenniaimportdefault_cmds,utils
+
+# this is now in the outermost scope and takes two args!
+defecho(caller,args):
+ "Called after 10 seconds."
+ shout=args
+ string="You hear an echo: %s ... %s ... %s"
+ string=string%(shout.upper(),shout.capitalize(),shout.lower())
+ caller.msg(string)
+
+classCmdEcho(default_cmds.MuxCommand):
+ """
+ wait for an echo
+
+ Usage:
+ echo <string>
+
+ Calls and waits for an echo
+ """
+ key="echo"
+ locks="cmd:all()"
+
+ deffunc(self):
+ """
+ This is called at the initial shout.
+ """
+ self.caller.msg("You shout '%s' and wait for an echo ..."%self.args)
+ # this waits non-blocking for 10 seconds, then calls echo(self.caller, self.args)
+ utils.delay(10,echo,self.caller,self.args,persistent=True)# changes!
+
+
+
+
Above you notice two changes:
+
+
The callback (echo) was moved out of the class and became its own stand-alone function in the
+outermost scope of the module. It also now takes caller and args as arguments (it doesn’t have
+access to them directly since this is now a stand-alone function).
+
utils.delay specifies the echo function (not self.echo - it’s no longer a method!) and sends
+self.caller and self.args as arguments for it to use. We also set persistent=True.
+
+
The reason for this change is because Evennia needs to pickle the callback into storage and it
+cannot do this correctly when the method sits on the command class. Now this behave the same as the
+first version except if you reload (or even shut down) the server mid-delay it will still fire the
+callback when the server comes back up (it will resume the countdown and ignore the downtime).
A prompt is quite common in MUDs. The prompt display useful details about your character that you
+are likely to want to keep tabs on at all times, such as health, magical power etc. It might also
+show things like in-game time, weather and so on. Many modern MUD clients (including Evennia’s own
+webclient) allows for identifying the prompt and have it appear in a correct location (usually just
+above the input line). Usually it will remain like that until it is explicitly updated.
A prompt is sent using the prompt keyword to the msg() method on objects. The prompt will be
+sent without any line breaks.
+
self.msg(prompt="HP: 5, MP: 2, SP: 8")
+
+
+
You can combine the sending of normal text with the sending (updating of the prompt):
+
self.msg("This is a text",prompt="This is a prompt")
+
+
+
You can update the prompt on demand, this is normally done using OOB-tracking of the relevant
+Attributes (like the character’s health). You could also make sure that attacking commands update
+the prompt when they cause a change in health, for example.
+
Here is a simple example of the prompt sent/updated from a command class:
+
fromevenniaimportCommand
+
+ classCmdDiagnose(Command):
+ """
+ see how hurt your are
+
+ Usage:
+ diagnose [target]
+
+ This will give an estimate of the target's health. Also
+ the target's prompt will be updated.
+ """
+ key="diagnose"
+
+ deffunc(self):
+ ifnotself.args:
+ target=self.caller
+ else:
+ target=self.search(self.args)
+ ifnottarget:
+ return
+ # try to get health, mana and stamina
+ hp=target.db.hp
+ mp=target.db.mp
+ sp=target.db.sp
+
+ ifNonein(hp,mp,sp):
+ # Attributes not defined
+ self.caller.msg("Not a valid target!")
+ return
+
+ text="You diagnose %s as having " \
+ "%i health, %i mana and %i stamina." \
+ %(hp,mp,sp)
+ prompt="%i HP, %i MP, %i SP"%(hp,mp,sp)
+ self.caller.msg(text,prompt=prompt)
+
The prompt sent as described above uses a standard telnet instruction (the Evennia web client gets a
+special flag). Most MUD telnet clients will understand and allow users to catch this and keep the
+prompt in place until it updates. So in principle you’d not need to update the prompt every
+command.
+
However, with a varying user base it can be unclear which clients are used and which skill level the
+users have. So sending a prompt with every command is a safe catch-all. You don’t need to manually
+go in and edit every command you have though. Instead you edit the base command class for your
+custom commands (like MuxCommand in your mygame/commands/command.py folder) and overload the
+at_post_cmd() hook. This hook is always called after the main func() method of the Command.
If you want to add something small like this to Evennia’s default commands without modifying them
+directly the easiest way is to just wrap those with a multiple inheritance to your own base class:
+
# in (for example) mygame/commands/mycommands.py
+
+fromevenniaimportdefault_cmds
+# our custom MuxCommand with at_post_cmd hook
+fromcommands.commandimportMuxCommand
+
+# overloading the look command
+classCmdLook(default_cmds.CmdLook,MuxCommand):
+ pass
+
+
+
The result of this is that the hooks from your custom MuxCommand will be mixed into the default
+CmdLook through multiple inheritance. Next you just add this to your default command set:
Command Sets are intimately linked with Commands and you should be familiar with
+Commands before reading this page. The two pages were split for ease of reading.
+
A Command Set (often referred to as a CmdSet or cmdset) is the basic unit for storing one or more
+Commands. A given Command can go into any number of different command sets. Storing Command
+classes in a command set is the way to make commands available to use in your game.
+
When storing a CmdSet on an object, you will make the commands in that command set available to the
+object. An example is the default command set stored on new Characters. This command set contains
+all the useful commands, from look and inventory to @dig and @reload
+(permissions then limit which players may use them, but that’s a separate
+topic).
+
When an account enters a command, cmdsets from the Account, Character, its location, and elsewhere
+are pulled together into a merge stack. This stack is merged together in a specific order to
+create a single “merged” cmdset, representing the pool of commands available at that very moment.
+
An example would be a Window object that has a cmdset with two commands in it: lookthroughwindow and openwindow. The command set would be visible to players in the room with the window,
+allowing them to use those commands only there. You could imagine all sorts of clever uses of this,
+like a Television object which had multiple commands for looking at it, switching channels and so
+on. The tutorial world included with Evennia showcases a dark room that replaces certain critical
+commands with its own versions because the Character cannot see.
+
If you want a quick start into defining your first commands and using them with command sets, you
+can head over to the Adding Command Tutorial which steps through things
+without the explanations.
A CmdSet is, as most things in Evennia, defined as a Python class inheriting from the correct parent
+(evennia.CmdSet, which is a shortcut to evennia.commands.cmdset.CmdSet). The CmdSet class only
+needs to define one method, called at_cmdset_creation(). All other class parameters are optional,
+but are used for more advanced set manipulation and coding (see the [merge rules](Command-
+Sets#merge-rules) section).
+
# file mygame/commands/mycmdset.py
+
+fromevenniaimportCmdSet
+
+# this is a theoretical custom module with commands we
+# created previously: mygame/commands/mycommands.py
+fromcommandsimportmycommands
+
+classMyCmdSet(CmdSet):
+ defat_cmdset_creation(self):
+ """
+ The only thing this method should need
+ to do is to add commands to the set.
+ """
+ self.add(mycommands.MyCommand1())
+ self.add(mycommands.MyCommand2())
+ self.add(mycommands.MyCommand3())
+
+
+
The CmdSet’s add() method can also take another CmdSet as input. In this case all the commands
+from that CmdSet will be appended to this one as if you added them line by line:
+
defat_cmdset_creation():
+ ...
+ self.add(AdditionalCmdSet)# adds all command from this set
+ ...
+
+
+
If you added your command to an existing cmdset (like to the default cmdset), that set is already
+loaded into memory. You need to make the server aware of the code changes:
+
@reload
+
+
+
You should now be able to use the command.
+
If you created a new, fresh cmdset, this must be added to an object in order to make the commands
+within available. A simple way to temporarily test a cmdset on yourself is use the @py command to
+execute a python snippet:
An object can only have one “default” cmdset (but can also have none). This is meant as a safe fall-
+back even if all other cmdsets fail or are removed. It is always persistent and will not be affected
+by cmdset.delete(). To remove a default cmdset you must explicitly call cmdset.remove_default().
+
Command sets are often added to an object in its at_object_creation method. For more examples of
+adding commands, read the Step by step tutorial. Generally you can
+customize which command sets are added to your objects by using self.cmdset.add() or
+self.cmdset.add_default().
+
+
Important: Commands are identified uniquely by key or alias (see Commands). If any
+overlap exists, two commands are considered identical. Adding a Command to a command set that
+already has an identical command will replace the previous command. This is very important. You
+must take this behavior into account when attempting to overload any default Evennia commands with
+your own. Otherwise, you may accidentally “hide” your own command in your command set when adding a
+new one that has a matching alias.
There are several extra flags that you can set on CmdSets in order to modify how they work. All are
+optional and will be set to defaults otherwise. Since many of these relate to merging cmdsets,
+you might want to read the [Adding and Merging Command Sets](./Command-Sets.md#adding-and-merging-
+command-sets) section for some of these to make sense.
+
+
key (string) - an identifier for the cmdset. This is optional, but should be unique. It is used
+for display in lists, but also to identify special merging behaviours using the key_mergetype
+dictionary below.
+
mergetype (string) - allows for one of the following string values: “Union”, “Intersect”,
+“Replace”, or “Remove”.
+
priority (int) - This defines the merge order of the merge stack - cmdsets will merge in rising
+order of priority with the highest priority set merging last. During a merger, the commands from the
+set with the higher priority will have precedence (just what happens depends on the merge
+type). If priority is identical, the order in the
+merge stack determines preference. The priority value must be greater or equal to -100. Most in-
+game sets should usually have priorities between 0 and 100. Evennia default sets have priorities
+as follows (these can be changed if you want a different distribution):
+
+
EmptySet: -101 (should be lower than all other sets)
+
SessionCmdSet: -20
+
AccountCmdSet: -10
+
CharacterCmdSet: 0
+
ExitCmdSet: 101 (generally should always be available)
+
ChannelCmdSet: 101 (should usually always be available) - since exits never accept
+arguments, there is no collision between exits named the same as a channel even though the commands
+“collide”.
+
+
+
key_mergetype (dict) - a dict of key:mergetype pairs. This allows this cmdset to merge
+differently with certain named cmdsets. If the cmdset to merge with has a key matching an entry in
+key_mergetype, it will not be merged according to the setting in mergetype but according to the
+mode in this dict. Please note that this is more complex than it may seem due to the merge
+order of command sets. Please review that section
+before using key_mergetype.
+
duplicates (bool/None default None) - this determines what happens when merging same-priority
+cmdsets containing same-key commands together. Thedupicate option will only apply when merging
+the cmdset with this option onto one other cmdset with the same priority. The resulting cmdset will
+not retain this duplicate setting.
+
+
None (default): No duplicates are allowed and the cmdset being merged “onto” the old one
+will take precedence. The result will be unique commands. However, the system will assume this
+value to be True for cmdsets on Objects, to avoid dangerous clashes. This is usually the safe bet.
+
False: Like None except the system will not auto-assume any value for cmdsets defined on
+Objects.
+
True: Same-named, same-prio commands will merge into the same cmdset. This will lead to a
+multimatch error (the user will get a list of possibilities in order to specify which command they
+meant). This is is useful e.g. for on-object cmdsets (example: There is a redbutton and a greenbutton in the room. Both have a pressbutton command, in cmdsets with the same priority. This
+flag makes sure that just writing pressbutton will force the Player to define just which object’s
+command was intended).
+
+
+
no_objs this is a flag for the cmdhandler that builds the set of commands available at every
+moment. It tells the handler not to include cmdsets from objects around the account (nor from rooms
+or inventory) when building the merged set. Exit commands will still be included. This option can
+have three values:
+
+
None (default): Passthrough of any value set explicitly earlier in the merge stack. If never
+set explicitly, this acts as False.
+
True/False: Explicitly turn on/off. If two sets with explicit no_objs are merged,
+priority determines what is used.
+
+
+
no_exits - this is a flag for the cmdhandler that builds the set of commands available at every
+moment. It tells the handler not to include cmdsets from exits. This flag can have three values:
+
+
None (default): Passthrough of any value set explicitly earlier in the merge stack. If
+never set explicitly, this acts as False.
+
True/False: Explicitly turn on/off. If two sets with explicit no_exits are merged,
+priority determines what is used.
+
+
+
no_channels (bool) - this is a flag for the cmdhandler that builds the set of commands available
+at every moment. It tells the handler not to include cmdsets from available in-game channels. This
+flag can have three values:
+
+
None (default): Passthrough of any value set explicitly earlier in the merge stack. If
+never set explicitly, this acts as False.
+
True/False: Explicitly turn on/off. If two sets with explicit no_channels are merged,
+priority determines what is used.
When a user issues a command, it is matched against the [merged](./Command-Sets.md#adding-and-merging-
+command-sets) command sets available to the player at the moment. Which those are may change at any
+time (such as when the player walks into the room with the Window object described earlier).
+
The currently valid command sets are collected from the following sources:
+
+
The cmdsets stored on the currently active Session. Default is the empty
+SessionCmdSet with merge priority -20.
+
The cmdsets defined on the Account. Default is the AccountCmdSet with merge priority
+-10.
+
All cmdsets on the Character/Object (assuming the Account is currently puppeting such a
+Character/Object). Merge priority 0.
+
The cmdsets of all objects carried by the puppeted Character (checks the call lock). Will not be
+included if no_objs option is active in the merge stack.
+
The cmdsets of the Character’s current location (checks the call lock). Will not be included if
+no_objs option is active in the merge stack.
+
The cmdsets of objects in the current location (checks the call lock). Will not be included if
+no_objs option is active in the merge stack.
+
The cmdsets of Exits in the location. Merge priority +101. Will not be included if no_exits
+orno_objs option is active in the merge stack.
+
The channel cmdset containing commands for posting to all channels the account
+or character is currently connected to. Merge priority +101. Will not be included if no_channels
+option is active in the merge stack.
+
+
Note that an object does not have to share its commands with its surroundings. A Character’s
+cmdsets should not be shared for example, or all other Characters would get multi-match errors just
+by being in the same room. The ability of an object to share its cmdsets is managed by its call
+lock. For example, Character objects defaults to call:false() so that any
+cmdsets on them can only be accessed by themselves, not by other objects around them. Another
+example might be to lock an object with call:inside() to only make their commands available to
+objects inside them, or cmd:holds() to make their commands available only if they are held.
Note: This is an advanced topic. It’s very useful to know about, but you might want to skip it if
+this is your first time learning about commands.
+
CmdSets have the special ability that they can be merged together into new sets. Which of the
+ingoing commands end up in the merged set is defined by the merge rule and the relative
+priorities of the two sets. Removing the latest added set will restore things back to the way it
+was before the addition.
+
CmdSets are non-destructively stored in a stack inside the cmdset handler on the object. This stack
+is parsed to create the “combined” cmdset active at the moment. CmdSets from other sources are also
+included in the merger such as those on objects in the same room (like buttons to press) or those
+introduced by state changes (such as when entering a menu). The cmdsets are all ordered after
+priority and then merged together in reverse order. That is, the higher priority will be merged
+“onto” lower-prio ones. By defining a cmdset with a merge-priority between that of two other sets,
+you will make sure it will be merged in between them.
+The very first cmdset in this stack is called the Default cmdset and is protected from accidental
+deletion. Running obj.cmdset.delete() will never delete the default set. Instead one should add
+new cmdsets on top of the default to “hide” it, as described below. Use the special
+obj.cmdset.delete_default() only if you really know what you are doing.
+
CmdSet merging is an advanced feature useful for implementing powerful game effects. Imagine for
+example a player entering a dark room. You don’t want the player to be able to find everything in
+the room at a glance - maybe you even want them to have a hard time to find stuff in their backpack!
+You can then define a different CmdSet with commands that override the normal ones. While they are
+in the dark room, maybe the look and inv commands now just tell the player they cannot see
+anything! Another example would be to offer special combat commands only when the player is in
+combat. Or when being on a boat. Or when having taken the super power-up. All this can be done on
+the fly by merging command sets.
Basic rule is that command sets are merged in reverse priority order. That is, lower-prio sets are
+merged first and higher prio sets are merged “on top” of them. Think of it like a layered cake with
+the highest priority on top.
+
To further understand how sets merge, we need to define some examples. Let’s call the first command
+set A and the second B. We assume B is the command set already active on our object and
+we will merge A onto B. In code terms this would be done by object.cdmset.add(A).
+Remember, B is already active on object from before.
+
We let the A set have higher priority than B. A priority is simply an integer number. As
+seen in the list above, Evennia’s default cmdsets have priorities in the range -101 to 120. You
+are usually safe to use a priority of 0 or 1 for most game effects.
+
In our examples, both sets contain a number of commands which we’ll identify by numbers, like A1,A2 for set A and B1,B2,B3,B4 for B. So for that example both sets contain commands
+with the same keys (or aliases) “1” and “2” (this could for example be “look” and “get” in the real
+game), whereas commands 3 and 4 are unique to B. To describe a merge between these sets, we
+would write A1,A2+B1,B2,B3,B4=? where ? is a list of commands that depend on which merge
+type A has, and which relative priorities the two sets have. By convention, we read this
+statement as “New command set A is merged onto the old command set B to form ?”.
+
Below are the available merge types and how they work. Names are partly borrowed from Set
+theory.
+
+
Union (default) - The two cmdsets are merged so that as many commands as possible from each
+cmdset ends up in the merged cmdset. Same-key commands are merged by priority.
+
# Union
+ A1,A2 + B1,B2,B3,B4 = A1,A2,B3,B4
+
+
+
+
Intersect - Only commands found in both cmdsets (i.e. which have the same keys) end up in
+the merged cmdset, with the higher-priority cmdset replacing the lower one’s commands.
+
# Intersect
+ A1,A3,A5 + B1,B2,B4,B5 = A1,A5
+
+
+
+
Replace - The commands of the higher-prio cmdset completely replaces the lower-priority
+cmdset’s commands, regardless of if same-key commands exist or not.
+
# Replace
+ A1,A3 + B1,B2,B4,B5 = A1,A3
+
+
+
+
Remove - The high-priority command sets removes same-key commands from the lower-priority
+cmdset. They are not replaced with anything, so this is a sort of filter that prunes the low-prio
+set using the high-prio one as a template.
+
# Remove
+ A1,A3 + B1,B2,B3,B4,B5 = B2,B4,B5
+
+
+
+
+
Besides priority and mergetype, a command-set also takes a few other variables to control how
+they merge:
+
+
duplicates (bool) - determines what happens when two sets of equal priority merge. Default is
+that the new set in the merger (i.e. A above) automatically takes precedence. But if
+duplicates is true, the result will be a merger with more than one of each name match. This will
+usually lead to the player receiving a multiple-match error higher up the road, but can be good for
+things like cmdsets on non-player objects in a room, to allow the system to warn that more than one
+‘ball’ in the room has the same ‘kick’ command defined on it and offer a chance to select which
+ball to kick … Allowing duplicates only makes sense for Union and Intersect, the setting is
+ignored for the other mergetypes.
+
key_mergetypes (dict) - allows the cmdset to define a unique mergetype for particular cmdsets,
+identified by their cmdset key. Format is {CmdSetkey:mergetype}. Example:
+{'Myevilcmdset','Replace'} which would make sure for this set to always use ‘Replace’ on the
+cmdset with the key Myevilcmdset only, no matter what the main mergetype is set to.
+
+
+
Warning: The key_mergetypes dictionary can only work on the cmdset we merge onto. When using
+key_mergetypes it is thus important to consider the merge priorities - you must make sure that you
+pick a priority between the cmdset you want to detect and the next higher one, if any. That is, if
+we define a cmdset with a high priority and set it to affect a cmdset that is far down in the merge
+stack, we would not “see” that set when it’s time for us to merge. Example: Merge stack is
+A(prio=-10),B(prio=-5),C(prio=0),D(prio=5). We now merge a cmdset E(prio=10) onto this stack,
+with a key_mergetype={"B":"Replace"}. But priorities dictate that we won’t be merged onto B, we
+will be merged onto E (which is a merger of the lower-prio sets at this point). Since we are merging
+onto E and not B, our key_mergetype directive won’t trigger. To make sure it works we must make
+sure we merge onto B. Setting E’s priority to, say, -4 will make sure to merge it onto B and affect
+it appropriately.
+
+
More advanced cmdset example:
+
fromcommandsimportmycommands
+
+classMyCmdSet(CmdSet):
+
+ key="MyCmdSet"
+ priority=4
+ mergetype="Replace"
+ key_mergetypes={'MyOtherCmdSet':'Union'}
+
+ defat_cmdset_creation(self):
+ """
+ The only thing this method should need
+ to do is to add commands to the set.
+ """
+ self.add(mycommands.MyCommand1())
+ self.add(mycommands.MyCommand2())
+ self.add(mycommands.MyCommand3())
+
It is very important to remember that two commands are compared both by their key properties
+and by their aliases properties. If either keys or one of their aliases match, the two commands
+are considered the same. So consider these two Commands:
+
+
A Command with key “kick” and alias “fight”
+
A Command with key “punch” also with an alias “fight”
+
+
During the cmdset merging (which happens all the time since also things like channel commands and
+exits are merged in), these two commands will be considered identical since they share alias. It
+means only one of them will remain after the merger. Each will also be compared with all other
+commands having any combination of the keys and/or aliases “kick”, “punch” or “fight”.
+
… So avoid duplicate aliases, it will only cause confusion.
Commands are intimately linked to Command Sets and you need to read that page too to
+be familiar with how the command system works. The two pages were split for easy reading.
+
The basic way for users to communicate with the game is through Commands. These can be commands
+directly related to the game world such as look, get, drop and so on, or administrative
+commands such as examine or @dig.
+
The default commands coming with Evennia are ‘MUX-like’ in that they use @
+for admin commands, support things like switches, syntax with the ‘=’ symbol etc, but there is
+nothing that prevents you from implementing a completely different command scheme for your game. You
+can find the default commands in evennia/commands/default. You should not edit these directly -
+they will be updated by the Evennia team as new features are added. Rather you should look to them
+for inspiration and inherit your own designs from them.
+
There are two components to having a command running - the Command class and the Command
+Set (command sets were split into a separate wiki page for ease of reading).
+
+
A Command is a python class containing all the functioning code for what a command does - for
+example, a get command would contain code for picking up objects.
+
A Command Set (often referred to as a CmdSet or cmdset) is like a container for one or more
+Commands. A given Command can go into any number of different command sets. Only by putting the
+command set on a character object you will make all the commands therein available to use by that
+character. You can also store command sets on normal objects if you want users to be able to use the
+object in various ways. Consider a “Tree” object with a cmdset defining the commands climb and
+chop down. Or a “Clock” with a cmdset containing the single command check time.
+
+
This page goes into full detail about how to use Commands. To fully use them you must also read the
+page detailing Command Sets. There is also a step-by-step Adding Command
+Tutorial that will get you started quickly without the extra explanations.
All commands are implemented as normal Python classes inheriting from the base class Command
+(evennia.Command). You will find that this base class is very “bare”. The default commands of
+Evennia actually inherit from a child of Command called MuxCommand - this is the class that
+knows all the mux-like syntax like /switches, splitting by “=” etc. Below we’ll avoid mux-
+specifics and use the base Command class directly.
+
# basic Command definition
+ fromevenniaimportCommand
+
+ classMyCmd(Command):
+ """
+ This is the help-text for the command
+ """
+ key="mycommand"
+ defparse(self):
+ # parsing the command line here
+ deffunc(self):
+ # executing the command here
+
+
+
Here is a minimalistic command with no custom parsing:
+
fromevenniaimportCommand
+
+ classCmdEcho(Command):
+ key="echo"
+
+ deffunc(self):
+ # echo the caller's input back to the caller
+ self.caller.msg("Echo: {}".format(self.args)
+
+
+
+
You define a new command by assigning a few class-global properties on your inherited class and
+overloading one or two hook functions. The full gritty mechanic behind how commands work are found
+towards the end of this page; for now you only need to know that the command handler creates an
+instance of this class and uses that instance whenever you use this command - it also dynamically
+assigns the new command instance a few useful properties that you can assume to always be available.
In Evennia there are three types of objects that may call the command. It is important to be aware
+of this since this will also assign appropriate caller, session, sessid and account
+properties on the command body at runtime. Most often the calling type is Session.
+
+
A Session. This is by far the most common case when a user is entering a command in
+their client.
+
+
caller - this is set to the puppeted Object if such an object exists. If no
+puppet is found, caller is set equal to account. Only if an Account is not found either (such as
+before being logged in) will this be set to the Session object itself.
+
session - a reference to the Session object itself.
+
sessid - sessid.id, a unique integer identifier of the session.
+
account - the Account object connected to this Session. None if not logged in.
+
+
+
An Account. This only happens if account.execute_cmd() was used. No Session
+information can be obtained in this case.
+
+
caller - this is set to the puppeted Object if such an object can be determined (without
+Session info this can only be determined in MULTISESSION_MODE=0 or 1). If no puppet is found,
+this is equal to account.
+
session - None*
+
sessid - None*
+
account - Set to the Account object.
+
+
+
An Object. This only happens if object.execute_cmd() was used (for example by an
+NPC).
+
+
caller - This is set to the calling Object in question.
+
session - None*
+
sessid - None*
+
account - None
+
+
+
+
+
*): There is a way to make the Session available also inside tests run directly on Accounts and
+Objects, and that is to pass it to execute_cmd like so: account.execute_cmd("...",session=<Session>). Doing so will make the .session and .sessid properties available in the
+command.
+
+
+
+
Properties assigned to the command instance at run-time¶
+
Let’s say account Bob with a character BigGuy enters the command look at sword. After the
+system having successfully identified this as the “look” command and determined that BigGuy really
+has access to a command named look, it chugs the look command class out of storage and either
+loads an existing Command instance from cache or creates one. After some more checks it then assigns
+it the following properties:
+
+
caller - The character BigGuy, in this example. This is a reference to the object executing the
+command. The value of this depends on what type of object is calling the command; see the previous
+section.
+
session - the Session Bob uses to connect to the game and control BigGuy (see also
+previous section).
+
sessid - the unique id of self.session, for quick lookup.
cmdstring - the matched key for the command. This would be look in our example.
+
args - this is the rest of the string, except the command name. So if the string entered was
+look at sword, args would be ” at sword”. Note the space kept - Evennia would correctly
+interpret lookatsword too. This is useful for things like /switches that should not use space.
+In the MuxCommand class used for default commands, this space is stripped. Also see the
+arg_regex property if you want to enforce a space to make lookatsword give a command-not-found
+error.
+
obj - the game Object on which this command is defined. This need not be the caller,
+but since look is a common (default) command, this is probably defined directly on BigGuy - so
+obj will point to BigGuy. Otherwise obj could be an Account or any interactive object with
+commands defined on it, like in the example of the “check time” command defined on a “Clock” object.
+
cmdset - this is a reference to the merged CmdSet (see below) from which this command was
+matched. This variable is rarely used, it’s main use is for the [auto-help system](Help-
+System#command-auto-help-system) (Advanced note: the merged cmdset need NOT be the same as
+BigGuy.cmdset. The merged set can be a combination of the cmdsets from other objects in the room,
+for example).
+
raw_string - this is the raw input coming from the user, without stripping any surrounding
+whitespace. The only thing that is stripped is the ending newline marker.
.get_help(caller,cmdset) - Get the help entry for this command. By default the arguments are
+not
+used, but they could be used to implement alternate help-display systems.
+
.client_width() - Shortcut for getting the client’s screen-width. Note that not all clients will
+truthfully report this value - that case the settings.DEFAULT_SCREEN_WIDTH will be returned.
+
.styled_table(*args,**kwargs) - This returns an [EvTable](module-
+evennia.utils.evtable) styled based on the
+session calling this command. The args/kwargs are the same as for EvTable, except styling defaults
+are set.
+
.styled_header, _footer, separator - These will produce styled decorations for
+display to the user. They are useful for creating listings and forms with colors adjustable per-
+user.
Beyond the properties Evennia always assigns to the command at run-time (listed above), your job is
+to define the following class properties:
+
+
key (string) - the identifier for the command, like look. This should (ideally) be unique. A
+key can consist of more than one word, like “press button” or “pull left lever”. Note that both
+key and aliases below determine the identity of a command. So two commands are considered if
+either matches. This is important for merging cmdsets described below.
+
aliases (optional list) - a list of alternate names for the command (["glance","see","l"]).
+Same name rules as for key applies.
+
locks (string) - a lock definition, usually on the form cmd:<lockfuncs>. Locks is a
+rather big topic, so until you learn more about locks, stick to giving the lockstring "cmd:all()"
+to make the command available to everyone (if you don’t provide a lock string, this will be assigned
+for you).
+
help_category (optional string) - setting this helps to structure the auto-help into categories.
+If none is set, this will be set to General.
+
save_for_next (optional boolean). This defaults to False. If True, a copy of this command
+object (along with any changes you have done to it) will be stored by the system and can be accessed
+by the next command by retrieving self.caller.ndb.last_cmd. The next run command will either clear
+or replace the storage.
+
arg_regex (optional raw string): Used to force the parser to limit itself and tell it when the
+command-name ends and arguments begin (such as requiring this to be a space or a /switch). This is
+done with a regular expression. See the arg_regex section for the details.
+
auto_help (optional boolean). Defaults to True. This allows for turning off the auto-help
+system on a per-command basis. This could be useful if you
+either want to write your help entries manually or hide the existence of a command from help’s
+generated list.
+
is_exit (bool) - this marks the command as being used for an in-game exit. This is, by default,
+set by all Exit objects and you should not need to set it manually unless you make your own Exit
+system. It is used for optimization and allows the cmdhandler to easily disregard this command when
+the cmdset has its no_exits flag set.
+
is_channel (bool)- this marks the command as being used for an in-game channel. This is, by
+default, set by all Channel objects and you should not need to set it manually unless you make your
+own Channel system. is used for optimization and allows the cmdhandler to easily disregard this
+command when its cmdset has its no_channels flag set.
+
msg_all_sessions (bool): This affects the behavior of the Command.msg method. If unset
+(default), calling self.msg(text) from the Command will always only send text to the Session that
+actually triggered this Command. If set however, self.msg(text) will send to all Sessions relevant
+to the object this Command sits on. Just which Sessions receives the text depends on the object and
+the server’s MULTISESSION_MODE.
+
+
You should also implement at least two methods, parse() and func() (You could also implement
+perm(), but that’s not needed unless you want to fundamentally change how access checks work).
+
+
at_pre_cmd() is called very first on the command. If this function returns anything that
+evaluates to True the command execution is aborted at this point.
+
parse() is intended to parse the arguments (self.args) of the function. You can do this in any
+way you like, then store the result(s) in variable(s) on the command object itself (i.e. on self).
+To take an example, the default mux-like system uses this method to detect “command switches” and
+store them as a list in self.switches. Since the parsing is usually quite similar inside a command
+scheme you should make parse() as generic as possible and then inherit from it rather than re-
+implementing it over and over. In this way, the default MuxCommand class implements a parse()
+for all child commands to use.
+
func() is called right after parse() and should make use of the pre-parsed input to actually
+do whatever the command is supposed to do. This is the main body of the command. The return value
+from this method will be returned from the execution as a Twisted Deferred.
+
at_post_cmd() is called after func() to handle eventual cleanup.
+
+
Finally, you should always make an informative doc
+string (__doc__) at the top of your
+class. This string is dynamically read by the Help System to create the help entry
+for this command. You should decide on a way to format your help and stick to that.
+
Below is how you define a simple alternative “smile” command:
+
fromevenniaimportCommand
+
+classCmdSmile(Command):
+ """
+ A smile command
+
+ Usage:
+ smile [at] [<someone>]
+ grin [at] [<someone>]
+
+ Smiles to someone in your vicinity or to the room
+ in general.
+
+ (This initial string (the __doc__ string)
+ is also used to auto-generate the help
+ for this command)
+ """
+
+ key="smile"
+ aliases=["smile at","grin","grin at"]
+ locks="cmd:all()"
+ help_category="General"
+
+ defparse(self):
+ "Very trivial parser"
+ self.target=self.args.strip()
+
+ deffunc(self):
+ "This actually does things"
+ caller=self.caller
+
+ ifnotself.targetorself.target=="here":
+ string=f"{caller.key} smiles"
+ else:
+ target=caller.search(self.target)
+ ifnottarget:
+ return
+ string=f"{caller.key} smiles at {target.key}"
+
+ caller.location.msg_contents(string)
+
+
+
+
The power of having commands as classes and to separate parse() and func()
+lies in the ability to inherit functionality without having to parse every
+command individually. For example, as mentioned the default commands all
+inherit from MuxCommand. MuxCommand implements its own version of parse()
+that understands all the specifics of MUX-like commands. Almost none of the
+default commands thus need to implement parse() at all, but can assume the
+incoming string is already split up and parsed in suitable ways by its parent.
+
Before you can actually use the command in your game, you must now store it
+within a command set. See the Command Sets page.
The command parser is very general and does not require a space to end your command name. This means
+that the alias : to emote can be used like :smiles without modification. It also means
+getstone will get you the stone (unless there is a command specifically named getstone, then
+that will be used). If you want to tell the parser to require a certain separator between the
+command name and its arguments (so that getstone works but getstone gives you a ‘command not
+found’ error) you can do so with the arg_regex property.
+
The arg_regex is a raw regular expression string. The
+regex will be compiled by the system at runtime. This allows you to customize how the part
+immediately following the command name (or alias) must look in order for the parser to match for
+this command. Some examples:
+
+
commandnameargument (arg_regex=r"\s.+"): This forces the parser to require the command name
+to be followed by one or more spaces. Whatever is entered after the space will be treated as an
+argument. However, if you’d forget the space (like a command having no arguments), this would not
+match commandname.
+
commandname or commandnameargument (arg_regex=r"\s.+|$"): This makes both look and
+lookme work but lookme will not.
+
commandname/switchesarguments (arg_regex=r"(?:^(?:\s+|\/).*$)|^$". If you are using
+Evennia’s MuxCommand Command parent, you may wish to use this since it will allow /switches to
+work as well as having or not having a space.
+
+
The arg_regex allows you to customize the behavior of your commands. You can put it in the parent
+class of your command to customize all children of your Commands. However, you can also change the
+base default behavior for all Commands by modifying settings.COMMAND_DEFAULT_ARG_REGEX.
Normally you just use return in one of your Command class’ hook methods to exit that method. That
+will however still fire the other hook methods of the Command in sequence. That’s usually what you
+want but sometimes it may be useful to just abort the command, for example if you find some
+unacceptable input in your parse method. To exit the command this way you can raise
+evennia.InterruptCommand:
+
fromevenniaimportInterruptCommand
+
+classMyCommand(Command):
+
+ # ...
+
+ defparse(self):
+ # ...
+ # if this fires, `func()` and `at_post_cmd` will not
+ # be called at all
+ raiseInterruptCommand()
+
+
Sometimes you want to pause the execution of your command for a little while before continuing -
+maybe you want to simulate a heavy swing taking some time to finish, maybe you want the echo of your
+voice to return to you with an ever-longer delay. Since Evennia is running asynchronously, you
+cannot use time.sleep() in your commands (or anywhere, really). If you do, the entire game will
+be frozen for everyone! So don’t do that. Fortunately, Evennia offers a really quick syntax for
+making pauses in commands.
+
In your func() method, you can use the yield keyword. This is a Python keyword that will freeze
+the current execution of your command and wait for more before processing.
+
+
Note that you cannot just drop yield into any code and expect it to pause. Evennia will only
+pause for you if you yield inside the Command’s func() method. Don’t expect it to work anywhere
+else.
+
+
Here’s an example of a command using a small pause of five seconds between messages:
+
fromevenniaimportCommand
+
+classCmdWait(Command):
+ """
+ A dummy command to show how to wait
+
+ Usage:
+ wait
+
+ """
+
+ key="wait"
+ locks="cmd:all()"
+ help_category="General"
+
+ deffunc(self):
+ """Command execution."""
+ self.msg("Starting to wait ...")
+ yield5
+ self.msg("... This shows after 5 seconds. Waiting ...")
+ yield2
+ self.msg("... And now another 2 seconds have passed.")
+
+
+
The important line is the yield5 and yield2 lines. It will tell Evennia to pause execution
+here and not continue until the number of seconds given has passed.
+
There are two things to remember when using yield in your Command’s func method:
+
+
The paused state produced by the yield is not saved anywhere. So if the server reloads in the
+middle of your command pausing, it will not resume when the server comes back up - the remainder
+of the command will never fire. So be careful that you are not freezing the character or account in
+a way that will not be cleared on reload.
+
If you use yield you may not also use return<values> in your func method. You’ll get an
+error explaining this. This is due to how Python generators work. You can however use a “naked”
+return just fine. Usually there is no need for func to return a value, but if you ever do need
+to mix yield with a final return value in the same func, look at twisted.internet.defer.returnV
+alue.
The yield keyword can also be used to ask for user input. Again you can’t
+use Python’s input in your command, for it would freeze Evennia for
+everyone while waiting for that user to input their text. Inside a Command’s
+func method, the following syntax can also be used:
+
answer=yield("Your question")
+
+
+
Here’s a very simple example:
+
classCmdConfirm(Command):
+
+ """
+ A dummy command to show confirmation.
+
+ Usage:
+ confirm
+
+ """
+
+ key="confirm"
+
+ deffunc(self):
+ answer=yield("Are you sure you want to go on?")
+ ifanswer.strip().lower()in("yes","y"):
+ self.msg("Yes!")
+ else:
+ self.msg("No!")
+
+
+
This time, when the user enters the ‘confirm’ command, she will be asked if she wants to go on.
+Entering ‘yes’ or “y” (regardless of case) will give the first reply, otherwise the second reply
+will show.
+
+
Note again that the yield keyword does not store state. If the game reloads while waiting for
+the user to answer, the user will have to start over. It is not a good idea to use yield for
+important or complex choices, a persistent EvMenu might be more appropriate in this case.
Note: This is an advanced topic. Skip it if this is your first time learning about commands.
+
There are several command-situations that are exceptional in the eyes of the server. What happens if
+the account enters an empty string? What if the ‘command’ given is infact the name of a channel the
+user wants to send a message to? Or if there are multiple command possibilities?
+
Such ‘special cases’ are handled by what’s called system commands. A system command is defined
+in the same way as other commands, except that their name (key) must be set to one reserved by the
+engine (the names are defined at the top of evennia/commands/cmdhandler.py). You can find (unused)
+implementations of the system commands in evennia/commands/default/system_commands.py. Since these
+are not (by default) included in any CmdSet they are not actually used, they are just there for
+show. When the special situation occurs, Evennia will look through all valid CmdSets for your
+custom system command. Only after that will it resort to its own, hard-coded implementation.
+
Here are the exceptional situations that triggers system commands. You can find the command keys
+they use as properties on evennia.syscmdkeys:
+
+
No input (syscmdkeys.CMD_NOINPUT) - the account just pressed return without any input. Default
+is to do nothing, but it can be useful to do something here for certain implementations such as line
+editors that interpret non-commands as text input (an empty line in the editing buffer).
+
Command not found (syscmdkeys.CMD_NOMATCH) - No matching command was found. Default is to
+display the “Huh?” error message.
+
Several matching commands where found (syscmdkeys.CMD_MULTIMATCH) - Default is to show a list of
+matches.
+
User is not allowed to execute the command (syscmdkeys.CMD_NOPERM) - Default is to display the
+“Huh?” error message.
+
Channel (syscmdkeys.CMD_CHANNEL) - This is a Channel name of a channel you are
+subscribing to - Default is to relay the command’s argument to that channel. Such commands are
+created by the Comm system on the fly depending on your subscriptions.
+
New session connection (syscmdkeys.CMD_LOGINSTART). This command name should be put in the
+settings.CMDSET_UNLOGGEDIN. Whenever a new connection is established, this command is always
+called on the server (default is to show the login screen).
+
+
Below is an example of redefining what happens when the account doesn’t provide any input (e.g. just
+presses return). Of course the new system command must be added to a cmdset as well before it will
+work.
+
fromevenniaimportsyscmdkeys,Command
+
+ classMyNoInputCommand(Command):
+ "Usage: Just press return, I dare you"
+ key=syscmdkeys.CMD_NOINPUT
+ deffunc(self):
+ self.caller.msg("Don't just press return like that, talk to me!")
+
Normally Commands are created as fixed classes and used without modification. There are however
+situations when the exact key, alias or other properties is not possible (or impractical) to pre-
+code (Exits is an example of this).
+
To create a command with a dynamic call signature, first define the command body normally in a class
+(set your key, aliases to default values), then use the following call (assuming the command
+class you created is named MyCommand):
All keyword arguments you give to the Command constructor will be stored as a property on the
+command object. This will overload existing properties defined on the parent class.
+
Normally you would define your class and only overload things like key and aliases at run-time.
+But you could in principle also send method objects (like func) as keyword arguments in order to
+make your command completely customized at run-time.
The functionality of Exit objects in Evennia is not hard-coded in the engine. Instead
+Exits are normal typeclassed objects that auto-create a CmdSet on
+themselves when they load. This cmdset has a single dynamically created Command with the same
+properties (key, aliases and locks) as the Exit object itself. When entering the name of the exit,
+this dynamic exit-command is triggered and (after access checks) moves the Character to the exit’s
+destination.
+Whereas you could customize the Exit object and its command to achieve completely different
+behaviour, you will usually be fine just using the appropriate traverse_* hooks on the Exit
+object. But if you are interested in really changing how things work under the hood, check out
+evennia/objects/objects.py for how the Exit typeclass is set up.
Note: This is an advanced topic that can be skipped when first learning about Commands.
+
A Command class sitting on an object is instantiated once and then re-used. So if you run a command
+from object1 over and over you are in fact running the same command instance over and over (if you
+run the same command but sitting on object2 however, it will be a different instance). This is
+usually not something you’ll notice, since every time the Command-instance is used, all the relevant
+properties on it will be overwritten. But armed with this knowledge you can implement some of the
+more exotic command mechanism out there, like the command having a ‘memory’ of what you last entered
+so that you can back-reference the previous arguments etc.
+
+
Note: On a server reload, all Commands are rebuilt and memory is flushed.
Commands can also be created and added to a cmdset on the fly. Creating a class instance with a
+keyword argument, will assign that keyword argument as a property on this paricular command:
This will start the MyCommand with myvar and foo set as properties (accessable as self.myvar
+and self.foo). How they are used is up to the Command. Remember however the discussion from the
+previous section - since the Command instance is re-used, those properties will remain on the
+command as long as this cmdset and the object it sits is in memory (i.e. until the next reload).
+Unless myvar and foo are somehow reset when the command runs, they can be modified and that
+change will be remembered for subsequent uses of the command.
Note: This is an advanced topic mainly of interest to server developers.
+
Any time the user sends text to Evennia, the server tries to figure out if the text entered
+corresponds to a known command. This is how the command handler sequence looks for a logged-in user:
+
+
A user enters a string of text and presses enter.
+
The user’s Session determines the text is not some protocol-specific control sequence or OOB
+command, but sends it on to the command handler.
+
Evennia’s command handler analyzes the Session and grabs eventual references to Account and
+eventual puppeted Characters (these will be stored on the command object later). The caller
+property is set appropriately.
+
If input is an empty string, resend command as CMD_NOINPUT. If no such command is found in
+cmdset, ignore.
+
If command.key matches settings.IDLE_COMMAND, update timers but don’t do anything more.
+
The command handler gathers the CmdSets available to caller at this time:
+
+
The caller’s own currently active CmdSet.
+
CmdSets defined on the current account, if caller is a puppeted object.
+
CmdSets defined on the Session itself.
+
The active CmdSets of eventual objects in the same location (if any). This includes commands
+on Exits.
+
Sets of dynamically created System commands representing available
+Communications.
+
+
+
All CmdSets of the same priority are merged together in groups. Grouping avoids order-
+dependent issues of merging multiple same-prio sets onto lower ones.
+
All the grouped CmdSets are merged in reverse priority into one combined CmdSet according to
+each set’s merge rules.
+
Evennia’s command parser takes the merged cmdset and matches each of its commands (using its
+key and aliases) against the beginning of the string entered by caller. This produces a set of
+candidates.
+
The cmd parser next rates the matches by how many characters they have and how many percent
+matches the respective known command. Only if candidates cannot be separated will it return multiple
+matches.
+
+
If multiple matches were returned, resend as CMD_MULTIMATCH. If no such command is found in
+cmdset, return hard-coded list of matches.
+
If no match was found, resend as CMD_NOMATCH. If no such command is found in cmdset, give
+hard-coded error message.
+
+
+
If a single command was found by the parser, the correct command object is plucked out of
+storage. This usually doesn’t mean a re-initialization.
+
It is checked that the caller actually has access to the command by validating the lockstring
+of the command. If not, it is not considered as a suitable match and CMD_NOMATCH is triggered.
+
If the new command is tagged as a channel-command, resend as CMD_CHANNEL. If no such command
+is found in cmdset, use hard-coded implementation.
+
Assign several useful variables to the command instance (see previous sections).
+
Call at_pre_command() on the command instance.
+
Call parse() on the command instance. This is fed the remainder of the string, after the name
+of the command. It’s intended to pre-parse the string into a form useful for the func() method.
+
Call func() on the command instance. This is the functional body of the command, actually
+doing useful things.
The return value of Command.func() is a Twisted
+deferred.
+Evennia does not use this return value at all by default. If you do, you must
+thus do so asynchronously, using callbacks.
+
# in command class func()
+ defcallback(ret,caller):
+ caller.msg("Returned is %s"%ret)
+ deferred=self.execute_command("longrunning")
+ deferred.addCallback(callback,self.caller)
+
+
+
This is probably not relevant to any but the most advanced/exotic designs (one might use it to
+create a “nested” command structure for example).
+
The save_for_next class variable can be used to implement state-persistent commands. For example
+it can make a command operate on “it”, where it is determined by what the previous command operated
+on.
Apart from moving around in the game world and talking, players might need other forms of
+communication. This is offered by Evennia’s Comm system. Stock evennia implements a ‘MUX-like’
+system of channels, but there is nothing stopping you from changing things to better suit your
+taste.
+
Comms rely on two main database objects - Msg and Channel. There is also the TempMsg which
+mimics the API of a Msg but has no connection to the database.
The Msg object is the basic unit of communication in Evennia. A message works a little like an
+e-mail; it always has a sender (a Account) and one or more recipients. The recipients
+may be either other Accounts, or a Channel (see below). You can mix recipients to send the message
+to both Channels and Accounts if you like.
+
Once created, a Msg is normally not changed. It is peristently saved in the database. This allows
+for comprehensive logging of communications. This could be useful for allowing senders/receivers to
+have ‘mailboxes’ with the messages they want to keep.
senders - this is a reference to one or many Account or Objects (normally
+Characters) sending the message. This could also be an External Connection such as a message
+coming in over IRC/IMC2 (see below). There is usually only one sender, but the types can also be
+mixed in any combination.
+
receivers - a list of target Accounts, Objects (usually Characters) or
+Channels to send the message to. The types of receivers can be mixed in any combination.
+
header - this is a text field for storing a title or header for the message.
hide_from - this can optionally hold a list of objects, accounts or channels to hide this Msg
+from. This relationship is stored in the database primarily for optimization reasons, allowing for
+quickly post-filter out messages not intended for a given target. There is no in-game methods for
+setting this, it’s intended to be done in code.
+
+
You create new messages in code using evennia.create_message (or
+evennia.utils.create.create_message.)
evennia.comms.models also has TempMsg which mimics the API of Msg but is not connected to the
+database. TempMsgs are used by Evennia for channel messages by default. They can be used for any
+system expecting a Msg but when you don’t actually want to save anything.
Channels are Typeclassed entities, which mean they can be easily extended and their
+functionality modified. To change which channel typeclass Evennia uses, change
+settings.BASE_CHANNEL_TYPECLASS.
+
Channels act as generic distributors of messages. Think of them as “switch boards” redistributing
+Msg or TempMsg objects. Internally they hold a list of “listening” objects and any Msg (or
+TempMsg) sent to the channel will be distributed out to all channel listeners. Channels have
+Locks to limit who may listen and/or send messages through them.
+
The sending of text to a channel is handled by a dynamically created Command that
+always have the same name as the channel. This is created for each channel by the global
+ChannelHandler. The Channel command is added to the Account’s cmdset and normal command locks are
+used to determine which channels are possible to write to. When subscribing to a channel, you can
+then just write the channel name and the text to send.
+
The default ChannelCommand (which can be customized by pointing settings.CHANNEL_COMMAND_CLASS to
+your own command), implements a few convenient features:
+
+
It only sends TempMsg objects. Instead of storing individual entries in the database it instead
+dumps channel output a file log in server/logs/channel_<channelname>.log. This is mainly for
+practical reasons - we find one rarely need to query individual Msg objects at a later date. Just
+stupidly dumping the log to a file also means a lot less database overhead.
+
It adds a /history switch to view the 20 last messages in the channel. These are read from the
+end of the log file. One can also supply a line number to start further back in the file (but always
+20 entries at a time). It’s used like this:
+
> public/history
+ > public/history 35
+
+
+
+
+
There are two default channels created in stock Evennia - MudInfo and Public. MudInfo
+receives server-related messages meant for Admins whereas Public is open to everyone to chat on
+(all new accounts are automatically joined to it when logging in, it is useful for asking
+questions). The default channels are defined by the DEFAULT_CHANNELS list (see
+evennia/settings_default.py for more details).
+
You create new channels with evennia.create_channel (or evennia.utils.create.create_channel).
+
In code, messages are sent to a channel using the msg or tempmsg methods of channels:
The argument msgobj can be either a string, a previously constructed Msg or a TempMsg - in the
+latter cases all the following keywords are ignored since the message objects already contains all
+this information. If msgobj is a string, the other keywords are used for creating a new Msg or
+TempMsg on the fly, depending on if persistent is set or not. By default, a TempMsg is emitted
+for channel communication (since the default ChannelCommand instead logs to a file).
+
# assume we have a 'sender' object and a channel named 'mychan'
+
+ # manually sending a message to a channel
+ mychan.msg("Hello!",senders=[sender])
+
When you first connect to your game you are greeted by Evennia’s default connection screen.
+
==============================================================
+ Welcome to Evennia, version Beta-ra4d24e8a3cab+!
+
+ If you have an existing account, connect to it by typing:
+ connect <username> <password>
+ If you need to create an account, type (without the <>'s):
+ create <username> <password>
+
+ If you have spaces in your username, enclose it in quotes.
+ Enter help for more info. look will re-show this screen.
+==============================================================
+
+
+
Effective, but not very exciting. You will most likely want to change this to be more unique for
+your game. This is simple:
Evennia will look into this module and locate all globally defined strings in it. These strings
+are used as the text in your connection screen and are shown to the user at startup. If more than
+one such string/screen is defined in the module, a random screen will be picked from among those
+available.
You can also customize the Commands available to use while the connection screen is
+shown (connect, create etc). These commands are a bit special since when the screen is running
+the account is not yet logged in. A command is made available at the login screen by adding them to
+UnloggedinCmdSet in mygame/commands/default_cmdset.py. See Commands and the
+tutorial section on how to add new commands to a default command set.
One of the advantages of Evennia over traditional MUSH development systems is that Evennia is
+capable of integrating into enterprise level integration environments and source control. Because of
+this, it can also be the subject of automation for additional convenience, allowing a more
+streamlined development environment.
Continuous Integration (CI) is a development
+practice that requires developers to integrate code into a shared repository several times a day.
+Each check-in is then verified by an automated build, allowing teams to detect problems early.
+
For Evennia, continuous integration allows an automated build process to:
+
+
Pull down a latest build from Source Control.
+
Run migrations on the backing SQL database.
+
Automate additional unique tasks for that project.
Templates are fancy objects in TeamCity that allow an administrator to define build steps that are
+shared between one or more build projects. Assigning a VCS Root (Source Control) is unnecessary at
+this stage, primarily you’ll be worrying about the build steps and your default parameters (both
+visible on the tabs to the left.)
In this template, you’ll be outlining the steps necessary to build your specific game. (A number of
+sample scripts are provided under this section below!) Click Build Steps and prepare your general
+flow. For this example, we will be doing a few basic example steps:
We do this to update ports or other information that make your production environment unique
+from your development environment.
+
+
+
Making migrations and migrating the game database.
+
Publishing the game files.
+
Reloading the server.
+
+
For each step we’ll being use the “Command Line Runner” (a fancy name for a shell script executor).
+
+
Create a build step with the name: Transform Configuration
+
For the script add:
+
#!/bin/bash
+# Replaces the game configuration with one
+# appropriate for this deployment.
+
+CONFIG="%system.teamcity.build.checkoutDir%/server/conf/settings.py"
+MYCONF="%system.teamcity.build.checkoutDir%/server/conf/my.cnf"
+
+sed -e 's/TELNET_PORTS = [4000]/TELNET_PORTS = [%game.ports%]/g'"$CONFIG" > "$CONFIG".tmp && mv
+
If you look at the parameters side of the page after saving this script, you’ll notice that some new
+parameters have been populated for you. This is because we’ve included new teamcity configuration
+parameters that are populated when the build itself is ran. When creating projects that inherit this
+template, we’ll be able to fill in or override those parameters for project-specific configuration.
+
+
Go ahead and create another build step called “Make Database Migration”
+
+
If you’re using SQLLite on your game, it will be prudent to change working directory on this
+step to: %game.dir%
+
+
+
In this script include:
+
#!/bin/bash
+# Update the DB migration
+
+LOGDIR="server/logs"
+
+. %evenv.dir%/bin/activate
+
+# Check that the logs directory exists.
+if[ ! -d "$LOGDIR"];then
+ # Control will enter here if $LOGDIR doesn't exist.
+ mkdir "$LOGDIR"
+fi
+
+evennia makemigrations
+
+
+
+
Create yet another build step, this time named: “Execute Database Migration”:
+
+
If you’re using SQLLite on your game, it will be prudent to change working directory on this
+step to: %game.dir%
+
#!/bin/bash
+# Apply the database migration.
+
+LOGDIR="server/logs"
+
+. %evenv.dir%/bin/activate
+
+# Check that the logs directory exists.
+if[ ! -d "$LOGDIR"];then
+ # Control will enter here if $LOGDIR doesn't exist.
+ mkdir "$LOGDIR"
+fi
+
+evennia migrate
+
+
+
+
+
+
+
Our next build step is where we actually publish our build. Up until now, all work on game has been
+done in a ‘work’ directory on TeamCity’s build agent. From that directory we will now copy our files
+to where our game actually exists on the local server.
+
+
Create a new build step called “Publish Build”:
+
+
If you’re using SQLLite on your game, be sure to order this step ABOVE the Database Migration
+steps. The build order will matter!
+
#!/bin/bash
+# Publishes the build to the proper build directory.
+
+DIRECTORY="%game.dir%"
+
+if[ ! -d "$DIRECTORY"];then
+ # Control will enter here if $DIRECTORY doesn't exist.
+ mkdir "$DIRECTORY"
+fi
+
+# Copy all the files.
+cp -ruv %teamcity.build.checkoutDir%/* "$DIRECTORY"
+chmod -R 775"$DIRECTORY"
+
+
+
+
+
+
+
Finally the last script will reload our game for us.
+
+
Create a new script called “Reload Game”:
+
+
The working directory on this build step will be: %game.dir%
+
#!/bin/bash
+# Apply the database migration.
+
+LOGDIR="server/logs"
+PIDDIR="server/server.pid"
+
+. %evenv.dir%/bin/activate
+
+# Check that the logs directory exists.
+if[ ! -d "$LOGDIR"];then
+ # Control will enter here if $LOGDIR doesn't exist.
+ mkdir "$LOGDIR"
+fi
+
+# Check that the server is running.
+if[ -d "$PIDDIR"];then
+ # Control will enter here if the game is running.
+ evennia reload
+fi
+
+
+
+
+
+
+
Now the template is ready for use! It would be useful this time to revisit the parameters page and
+set the evenv parameter to the directory where your virtualenv exists: IE “/srv/mush/evenv”.
Now it’s time for the last few steps to set up a CI environment.
+
+
Return to the Evennia Project overview/administration page.
+
Create a new Sub-Project called “Production”
+
+
This will be the category that holds our actual game.
+
+
+
Create a new Build Configuration in Production with the name of your MUSH.
+
+
Base this configuration off of the continuous-integration template we made earlier.
+
+
+
In the build configuration, enter VCS roots and create a new VCS root that points to the
+branch/version control that you are using.
+
Go to the parameters page and fill in the undefined parameters for your specific configuration.
+
If you wish for the CI to run every time a commit is made, go to the VCS triggers and add one for
+“On Every Commit”.
+
+
And you’re done! At this point, you can return to the project overview page and queue a new build
+for your game. If everything was set up correctly, the build will complete successfully. Additional
+build steps could be added or removed at this point, adding some features like Unit Testing or more!
This system is still WIP and many things are bound to change!
+
+
Contributing to the docs is is like contributing to the rest of Evennia: Check out the branch of Evennia
+you want to edit the documentation for. Create your own work-branch, make your changes to files
+in evennia/docs/source/ and make a PR for it!
+
The documentation source files are *.md (Markdown) files found in evennia/docs/source/.
+Markdown files are simple text files that can be edited with a normal text editor. They can also
+contain raw HTML directives (but that is very rarely needed). They use
+the Markdown syntax with MyST extensions.
+
+
Important
+
You do not need to be able to test/build the docs locally to contribute a documentation PR.
+We’ll resolve any issues when we merge and build documentation. If you still want to build
+the docs for yourself, instructions are at the end of this document.
The sources are organized into several rough categories, with only a few administrative documents
+at the root of evennia/docs/source/. The folders are named in singular form since they will
+primarily be accessed as link refs (e.g. Component/Accounts)
+
+
source/Components/ are docs describing separate Evennia building blocks, that is, things
+that you can import and use. This extends and elaborates on what can be found out by reading
+the api docs themselves. Example are documentation for Accounts, Objects and Commands.
+
source/Concepts/ describes how larger-scale features of Evennia hang together - things that
+can’t easily be broken down into one isolated component. This can be general descriptions of
+how Models and Typeclasses interact to the path a message takes from the client to the server
+and back.
+
source/Setup/ holds detailed docs on installing, running and maintaining the Evennia server and
+the infrastructure around it.
+
source/Coding/ has help on how to interact with, use and navigate the Evennia codebase itself.
+This also has non-Evennia-specific help on general development concepts and how to set up a sane
+development environment.
+
source/Contribs/ holds documentation specifically for packages in the evennia/contribs/ folder.
+Any contrib-specific tutorials will be found here instead of in Howtos
+
source/Howtos/ holds docs that describe how to achieve a specific goal, effect or
+result in Evennia. This is often on a tutorial or FAQ form and will refer to the rest of the
+documentation for further reading.
+
+
source/Howtos/Starting/ holds all documents part of the initial tutorial sequence.
+
+
+
+
Other files and folders:
+
+
source/api/ contains the auto-generated API documentation as .rst files. Don’t edit these
+files manually, your changes will be lost. To refer to these files, use api: followed by
+the Python path, for example [rpsystemcontrib](evennia.contrib.rpsystem).
+
source/_templates and source/_static should not be modified unless adding a new doc-page
+feature or changing the look of the HTML documentation.
+
conf.py holds the Sphinx configuration. It should usually not be modified except to update
+the Evennia version on a new branch.
The format used for Evennia’s docs is Markdown (Commonmark). While markdown
+supports a few alternative forms for some of these, we try to stick to the below forms for consistency.
We use # to indicate sections/headings. The more # the more of a sub-heading it is (will get
+smaller and smaller font).
+
+
#Heading
+
##SubHeading
+
###SubSubHeading
+
####SubSubSubHeading
+
+
+
Don’t use the same heading/subheading name more than once in one page. While Markdown
+does not prevent it, it will make it impossible to refer to that heading uniquely.
+The Evennia documentation preparser will detect this and give you an error.
A blockquote will create an indented block. It’s useful for emphasis and is
+added by starting one or more lines with >. For ‘notes’ you can also use
+an explicit Note.
Most links will be to other pages of the documentation or to Evennia’s API docs. Each document
+heading can be referenced. The reference always starts with #. The heading-name is always
+given in lowercase and ignores any non-letters. Spaces in the heading title are replaced with
+a single dash -.
+
As an example, let’s assume the following is the contents of a file Menu-stuff.md:
+
# Menu items
+
+Sometext...
+
+## A yes/no? example
+
+Somemoretext...
+
+
+
+
From inside the same file you can refer to each heading as
It’s fine to not include the .md file ending in the reference. The Evennia doc-build process
+will correct for this (and also insert any needed relative paths in the reference).
The documentation contains auto-generated documentation for all of Evennia’s source code. You
+can direct the reader to the sources by just giving the python-path to the location of the
+resource under the evennia/ repository:
Note that you can’t refer to files in the mygame folder this way. The game folder is generated
+dynamically and is not part of the api docs. Refer to the parent classes in evennia where possible.
These are links to resources outside of the documentation. We also provide some convenient shortcuts.
+
+
[linkname](https://evennia.com) - link to an external website.
+
[linkname](https://github.com/evennia/evennia/blob/master/evennia/objects/objects.py) - this is a shortcut to point to a location in the
+official Evennia repository on Github. Note that you must use / and give the full file name. By
+default this is code in the master branch.
+
[linkname](https://github.com/evennia/evennia/issues/new/choose) - this is a shortcut to the Evennia github issue-creation page.
+
+
+
Note that if you want to refer to code, it’s usually better to link to the API as
+described above.
Urls can get long and if you are using the same url/reference in many places it can get a
+little cluttered. So you can also put the url as a ‘footnote’ at the end of your document.
+You can then refer to it by putting your reference within square brackets []. Here’s an example:
As seen, the Markdown syntax can be pretty sloppy (columns don’t need to line up) as long as you
+include the heading separators and make sure to add the correct number of | on every line.
It’s common to want to mark something to be displayed verbatim - just as written - without any
+Markdown parsing. In running text, this is done using backticks (`), like `verbatim text` becomes
+verbatimtext.
+
If you want to put the verbatim text on its own line, you can do so easily by simply indenting
+it 4 spaces (add empty lines on each side for readability too):
A special ‘verbatim’ case is code examples - we want them to get code-highlighting for readability.
+This is done by using the triple-backticks and specify which language we use:
Markdown is easy to read and use. But while it does most of what we need, there are some things it’s
+not quite as expressive as it needs to be. For this we use extended MyST syntax. This is
+on the form
This kind of note may pop more than doing a >Note:....
+
```{note}
+
+This is some noteworthy content that stretches over more than one line to show how the content indents.
+Also the important/warning notes indents like this.
+
+```
+
+
+
+
Note
+
This is some noteworthy content that stretches over more than one line to show how the content indents.
+Also the important/warning notes indents like this.
This will display an informative sidebar that floats to the side of regular content. This is useful
+for example to remind the reader of some concept relevant to the text.
+
```{sidebar} Things to remember
+
+- There can be bullet lists
+- in here.
+
+Separate sections with
+
+an empty line.
+```
+
+
+
+
Hint: If wanting to make sure to have the next header appear on a row of its own (rather than
+squeezed to the left of the sidebar), one can embed a plain HTML string in the markdown like so:
The regular Markdown Python codeblock is usually enough but for more direct control over the style, one
+can also use the {code-block} directive that takes a set of additional :options::
+
```{code-block} python
+:linenos:
+:emphasize-lines: 1-2,8
+:caption: An example code block
+:name: A full code block example
+
+from evennia import Command
+class CmdEcho(Command):
+ """
+ Usage: echo <arg>
+ """
+ key = "echo"
+ def func(self):
+ self.caller.msg(self.args.strip())
+```
+
Here, :linenos: turns on line-numbers and :emphasize-lines: allows for emphasizing certain lines
+in a different color. The :caption: shows an instructive text and :name: is used to reference
+this
+block through the link that will appear (so it should be unique for a given document).
The source code docstrings will be parsed as Markdown. When writing a module docstring, you can use Markdown formatting,
+including header levels down to 4th level (####SubSubSubHeader). After the module documentation it’s
+a good idea to end with four dashes ----. This will create a visible line between the documentation and the
+class/function docs to follow.
+
All non-private classes, methods and functions must have a Google-style docstring, as per the
+[Evennia coding style guidelines][github:evennia/CODING_STYLE.md]. This will then be correctly formatted
+into pretty api docs.
Evennia leverages Sphinx with the MyST extension, which allows us
+to write our docs in light-weight Markdown (more specifically CommonMark, like on github)
+rather than Sphinx’ normal ReST syntax. The MyST parser allows for some extra syntax to
+make us able to express more complex displays than plain Markdown can.
+
For autodoc-generation generation, we use the sphinx-napoleon
+extension to understand our friendly Google-style docstrings used in classes and functions etc.
The sources in evennia/docs/source/ are built into a documentation using the
+Sphinx static generator system. To do this locally you need to use a
+system with make (Linux/Unix/Mac or Windows-WSL). Lacking
+that, you could in principle also run the sphinx build-commands manually - read
+the evennia/docs/Makefile to see which commands are run by the make-commands
+referred to in this document.
+
You don’t necessarily have to build the docs locally to contribute. Markdown is
+not hard and is very readable on its raw text-form.
+
You can furthermore get a good feel for how things will look using a
+Markdown-viewer like Grip. Editors like ReText or IDE’s like
+PyCharm also have native Markdown previews. Building the docs locally is
+however the only way to make sure the outcome is exactly as you expect. The process
+will also find any mistakes you made, like making a typo in a link.
This is the fastest way to compile and view your changes. It will only build
+the main documentation pages and not the API auto-docs or versions. All is
+done in your terminal/console.
+
+
(Optional, but recommended): Activate a virtualenv with Python 3.7.
+
cd to into the evennia/docs folder.
+
Install the documentation-build requirements:
+
makeinstall
+or
+pipinstall-rrequirements.txt
+
+
+
+
Next, build the html-based documentation (re-run this in the future to build your changes):
+
makequick
+
+
+
+
Note any errors from files you have edited.
+
The html-based documentation will appear in the new
+folder evennia/docs/build/html/.
+
Use a web browser to open file://<path-to-folder>/evennia/docs/build/html/index.html and view
+the docs. Note that you will get errors if clicking a link to the auto-docs, because you didn’t
+build them!
The full documentation includes both the doc pages and the API documentation
+generated from the Evennia source. For this you must install Evennia and
+initialize a new game with a default database (you don’t need to have any server
+running)
+
+
It’s recommended that you use a virtualenv. Install your cloned version of Evennia into
+by pointing to the repo folder (the one containing /docs):
+
pipinstall-eevennia
+
+
+
+
Make sure you are in the parent folder containing your evennia/ repo (so two levels
+up from evennia/docs/).
+
Create a new game folder called exactly gamedir at the same level as your evennia
+repo with
+
evennia--initgamedir
+
+
+
+
Then cd into it and create a new, empty database. You don’t need to start the
+game or do any further changes after this.
+
evenniamigrate
+
+
+
+
This is how the structure should look at this point:
If you for some reason want to use another location of your gamedir/, or want it
+named something else (maybe you already use the name ‘gamedir’ for your development …),
+you can do so by setting the EVGAMEDIR environment variable to the absolute path
+of your alternative game dir. For example:
The full Evennia documentation contains docs from many Evennia
+versions, old and new. This is done by pulling documentation from Evennia’s old release
+branches and building them all so readers can choose which one to view. Only
+specific official Evennia branches will be built, so you can’t use this to
+build your own testing branch.
+
+
All local changes must have been committed to git first, since the versioned
+docs are built by looking at the git tree.
+
To build for local checking, run (mv stands for “multi-version”):
+
makemv-local
+
+
+
+
+
This is as close to the ‘real’ version of the docs as you can get locally. The different versions
+will be found under evennia/docs/build/versions/. During deploy a symlink latest will point
+to the latest version of the docs.
Releasing the official docs requires git-push access the the Evennia gh-pages branch
+on github. So there is no risk of you releasing your local changes accidentally.
+
+
To deploy docs in two steps
+
makemv-local
+makedeploy
+
+
+
+
If you know what you are doing you can also do build + deploy in one step:
Even if you are not keen on working on the server code yourself, just spreading the word is a big
+help - it will help attract more people which leads to more feedback, motivation and interest.
+Consider writing about Evennia on your blog or in your favorite (relevant) forum. Write a review
+somewhere (good or bad, we like feedback either way). Rate it on places like ohloh. Talk
+about it to your friends … that kind of thing.
The best way to support Evennia is to become an Evennia patron. Evennia is a free,
+open-source project and any monetary donations you want to offer are completely voluntary. See it as
+a way of announcing that you appreciate the work done - a tip of the hat! A patron donates a
+(usually small) sum every month to show continued support. If this is not your thing you can also
+show your appreciation via a one-time donation (this is a PayPal link but you don’t need
+PayPal yourself).
Evennia depends heavily on good documentation and we are always looking for extra eyes and hands to
+improve it. Even small things such as fixing typos are a great help!
+
The documentation is a wiki and as long as you have a GitHub account you can edit it. It can be a
+good idea to discuss in the chat or forums if you want to add new pages/tutorials. Otherwise, it
+goes a long way just pointing out wiki errors so we can fix them (in an Issue or just over
+chat/forum).
We always need more eyes and hands on the code. Even if you don’t feel confident with tackling a
+[bug or feature][issues], just correcting typos, adjusting formatting or simply using the thing
+and reporting when stuff doesn’t make sense helps us a lot.
+
The most elegant way to contribute code to Evennia is to use GitHub to create a fork of the
+Evennia repository and make your changes to that. Refer to the [Forking Evennia](Version-
+Control#forking-evennia) version
+control instructions for detailed instructions.
+
Once you have a fork set up, you can not only work on your own game in a separate branch, you can
+also commit your fixes to Evennia itself. Make separate branches for all Evennia additions you do -
+don’t edit your local master or develop branches directly. It will make your life a lot easier.
+If you have a change that you think is suitable for the main Evennia repository, you issue a [Pull
+Request][pullrequest]. This will let Evennia devs know you have stuff to share. Bug fixes should
+generally be done against the master branch of Evennia, while new features/contribs should go into
+the develop branch. If you are unsure, just pick one and we’ll figure it out.
To help with Evennia development it’s recommended to do so using a fork repository as described
+above. But for small, well isolated fixes you are also welcome to submit your suggested Evennia
+fixes/addendums as a [patch][patch].
+
You can include your patch in an Issue or a Mailing list post. Please avoid pasting the full patch
+text directly in your post though, best is to use a site like Pastebin and
+just supply the link.
While Evennia’s core is pretty much game-agnostic, it also has a contrib/ directory. The contrib
+directory contains game systems that are specialized or useful only to certain types of games. Users
+are welcome to contribute to the contrib/ directory. Such contributions should always happen via a
+Forked repository as described above.
+
+
If you are unsure if your idea/code is suitable as a contrib, ask the devs before putting any
+work into it. This can also be a good idea in order to not duplicate efforts. This can also act as
+a check that your implementation idea is sound. We are, for example, unlikely to accept contribs
+that require large modifications of the game directory structure.
+
If your code is intended primarily as an example or shows a concept/principle rather than a
+working system, it is probably not suitable for contrib/. You are instead welcome to use it as
+part of a [new tutorial][tutorials]!
+
The code should ideally be contained within a single Python module. But if the contribution is
+large this may not be practical and it should instead be grouped in its own subdirectory (not as
+loose modules).
+
The contribution should preferably be isolated (only make use of core Evennia) so it can easily be
+dropped into use. If it does depend on other contribs or third-party modules, these must be clearly
+documented and part of the installation instructions.
+
The code itself should follow Evennia’s [Code style guidelines][codestyle].
+
The code must be well documented as described in our documentation style
+guide. Expect that your
+code will be read and should be possible to understand by others. Include comments as well as a
+header in all modules. If a single file, the header should include info about how to include the
+contrib in a game (installation instructions). If stored in a subdirectory, this info should go into
+a new README.md file within that directory.
+
Within reason, your contribution should be designed as genre-agnostic as possible. Limit the
+amount of game-style-specific code. Assume your code will be applied to a very different game than
+you had in mind when creating it.
+
To make the licensing situation clear we assume all contributions are released with the same
+license as Evennia. If this is not possible for some reason, talk to us and we’ll
+handle it on a case-by-case basis.
+
Your contribution must be covered by unit tests. Having unit tests will both help
+make your code more stable and make sure small changes does not break it without it being noticed,
+it will also help us test its functionality and merge it quicker. If your contribution is a single
+module, you can add your unit tests to evennia/contribs/tests.py. If your contribution is bigger
+and in its own sub-directory you could just put the tests in your own tests.py file (Evennia will
+find it automatically).
+
Merging of your code into Evennia is not guaranteed. Be ready to receive feedback and to be asked
+to make corrections or fix bugs. Furthermore, merging a contrib means the Evennia project takes on
+the responsibility of maintaining and supporting it. For various reasons this may be deemed to be
+beyond our manpower. However, if your code were to not be accepted for merger for some reason, we
+will instead add a link to your online repository so people can still find and use your work if they
+want.
This tutorial is moderately difficult in content. You might want to be familiar and at ease with
+some Python concepts (like properties) and possibly Django concepts (like queries), although this
+tutorial will try to walk you through the process and give enough explanations each time. If you
+don’t feel very confident with math, don’t hesitate to pause, go to the example section, which shows
+a tiny map, and try to walk around the code or read the explanation.
+
Evennia doesn’t have a coordinate system by default. Rooms and other objects are linked by location
+and content:
+
+
An object can be in a location, that is, another object. Like an exit in a room.
+
An object can access its content. A room can see what objects uses it as location (that would
+include exits, rooms, characters and so on).
+
+
This system allows for a lot of flexibility and, fortunately, can be extended by other systems.
+Here, I offer you a way to add coordinates to every room in a way most compliant with Evennia
+design. This will also show you how to use coordinates, find rooms around a given point for
+instance.
The first concept might be the most surprising at first glance: we will create coordinates as
+tags.
+
+
Why not attributes, wouldn’t that be easier?
+
+
It would. We could just do something like room.db.x=3. The advantage of using tags is that it
+will be easy and effective to search. Although this might not seem like a huge advantage right now,
+with a database of thousands of rooms, it might make a difference, particularly if you have a lot of
+things based on coordinates.
+
Rather than giving you a step-by-step process, I’ll show you the code. Notice that we use
+properties to easily access and update coordinates. This is a Pythonic approach. Here’s our first
+Room class, that you can modify in typeclasses/rooms.py:
+
# in typeclasses/rooms.py
+
+fromevenniaimportDefaultRoom
+
+classRoom(DefaultRoom):
+ """
+ Rooms are like any Object, except their location is None
+ (which is default). They also use basetype_setup() to
+ add locks so they cannot be puppeted or picked up.
+ (to change that, use at_object_creation instead)
+
+ See examples/object.py for a list of
+ properties and methods available on all Objects.
+ """
+
+ @property
+ defx(self):
+ """Return the X coordinate or None."""
+ x=self.tags.get(category="coordx")
+ returnint(x)ifisinstance(x,str)elseNone
+
+ @x.setter
+ defx(self,x):
+ """Change the X coordinate."""
+ old=self.tags.get(category="coordx")
+ ifoldisnotNone:
+ self.tags.remove(old,category="coordx")
+ ifxisnotNone:
+ self.tags.add(str(x),category="coordx")
+
+ @property
+ defy(self):
+ """Return the Y coordinate or None."""
+ y=self.tags.get(category="coordy")
+ returnint(y)ifisinstance(y,str)elseNone
+
+ @y.setter
+ defy(self,y):
+ """Change the Y coordinate."""
+ old=self.tags.get(category="coordy")
+ ifoldisnotNone:
+ self.tags.remove(old,category="coordy")
+ ifyisnotNone:
+ self.tags.add(str(y),category="coordy")
+
+ @property
+ defz(self):
+ """Return the Z coordinate or None."""
+ z=self.tags.get(category="coordz")
+ returnint(z)ifisinstance(z,str)elseNone
+
+ @z.setter
+ defz(self,z):
+ """Change the Z coordinate."""
+ old=self.tags.get(category="coordz")
+ ifoldisnotNone:
+ self.tags.remove(old,category="coordz")
+ ifzisnotNone:
+ self.tags.add(str(z),category="coordz")
+
+
+
If you aren’t familiar with the concept of properties in Python, I encourage you to read a good
+tutorial on the subject. This article on Python properties
+is well-explained and should help you understand the idea.
+
Let’s look at our properties for x. First of all is the read property.
+
@property
+ defx(self):
+ """Return the X coordinate or None."""
+ x=self.tags.get(category="coordx")
+ returnint(x)ifisinstance(x,str)elseNone
+
+
+
What it does is pretty simple:
+
+
It gets the tag of category "coordx". It’s the tag category where we store our X coordinate.
+The tags.get method will return None if the tag can’t be found.
+
We convert the value to an integer, if it’s a str. Remember that tags can only contain str,
+so we’ll need to convert it.
+
+
+
I thought tags couldn’t contain values?
+
+
Well, technically, they can’t: they’re either here or not. But using tag categories, as we have
+done, we get a tag, knowing only its category. That’s the basic approach to coordinates in this
+tutorial.
+
Now, let’s look at the method that will be called when we wish to set x in our room:
+
@x.setter
+ defx(self,x):
+ """Change the X coordinate."""
+ old=self.tags.get(category="coordx")
+ ifoldisnotNone:
+ self.tags.remove(old,category="coordx")
+ ifxisnotNone:
+ self.tags.add(str(x),category="coordx")
+
+
+
+
First, we remove the old X coordinate, if it exists. Otherwise, we’d end up with two tags in our
+room with “coordx” as their category, which wouldn’t do at all.
+
Then we add the new tag, giving it the proper category.
+
+
+
Now what?
+
+
If you add this code and reload your game, once you’re logged in with a character in a room as its
+location, you can play around:
It can help in shaping a truly logical world, in its geography, at least.
+
It can allow to look for specific rooms at given coordinates.
+
It can be good in order to quickly find the rooms around a location.
+
It can even be great in path-finding (finding the shortest path between two rooms).
+
+
So far, our coordinate system can help with 1., but not much else. Here are some methods that we
+could add to the Room typeclass. These methods will just be search methods. Notice that they are
+class methods, since we want to get rooms.
First, a simple one: how to find a room at a given coordinate? Say, what is the room at X=0, Y=0,
+Z=0?
+
classRoom(DefaultRoom):
+ # ...
+ @classmethod
+ defget_room_at(cls,x,y,z):
+ """
+ Return the room at the given location or None if not found.
+
+ Args:
+ x (int): the X coord.
+ y (int): the Y coord.
+ z (int): the Z coord.
+
+ Return:
+ The room at this location (Room) or None if not found.
+
+ """
+ rooms=cls.objects.filter(
+ db_tags__db_key=str(x),db_tags__db_category="coordx").filter(
+ db_tags__db_key=str(y),db_tags__db_category="coordy").filter(
+ db_tags__db_key=str(z),db_tags__db_category="coordz")
+ ifrooms:
+ returnrooms[0]
+
+ returnNone
+
+
+
This solution includes a bit of Django
+queries.
+Basically, what we do is reach for the object manager and search for objects with the matching tags.
+Again, don’t spend too much time worrying about the mechanism, the method is quite easy to use:
+
Room.get_room_at(5,2,-3)
+
+
+
Notice that this is a class method: you will call it from Room (the class), not an instance.
+Though you still can:
Here’s another useful method that allows us to look for rooms around a given coordinate. This is
+more advanced search and doing some calculation, beware! Look at the following section if you’re
+lost.
+
frommathimportsqrt
+
+classRoom(DefaultRoom):
+
+ # ...
+
+ @classmethod
+ defget_rooms_around(cls,x,y,z,distance):
+ """
+ Return the list of rooms around the given coordinates.
+
+ This method returns a list of tuples (distance, room) that
+ can easily be browsed. This list is sorted by distance (the
+ closest room to the specified position is always at the top
+ of the list).
+
+ Args:
+ x (int): the X coord.
+ y (int): the Y coord.
+ z (int): the Z coord.
+ distance (int): the maximum distance to the specified position.
+
+ Returns:
+ A list of tuples containing the distance to the specified
+ position and the room at this distance. Several rooms
+ can be at equal distance from the position.
+
+ """
+ # Performs a quick search to only get rooms in a square
+ x_r=list(reversed([str(x-i)foriinrange(0,distance+1)]))
+ x_r+=[str(x+i)foriinrange(1,distance+1)]
+ y_r=list(reversed([str(y-i)foriinrange(0,distance+1)]))
+ y_r+=[str(y+i)foriinrange(1,distance+1)]
+ z_r=list(reversed([str(z-i)foriinrange(0,distance+1)]))
+ z_r+=[str(z+i)foriinrange(1,distance+1)]
+ wide=cls.objects.filter(
+ db_tags__db_key__in=x_r,db_tags__db_category="coordx").filter(
+ db_tags__db_key__in=y_r,db_tags__db_category="coordy").filter(
+ db_tags__db_key__in=z_r,db_tags__db_category="coordz")
+
+ # We now need to filter down this list to find out whether
+ # these rooms are really close enough, and at what distance
+ # In short: we change the square to a circle.
+ rooms=[]
+ forroominwide:
+ x2=int(room.tags.get(category="coordx"))
+ y2=int(room.tags.get(category="coordy"))
+ z2=int(room.tags.get(category="coordz"))
+ distance_to_room=sqrt(
+ (x2-x)**2+(y2-y)**2+(z2-z)**2)
+ ifdistance_to_room<=distance:
+ rooms.append((distance_to_room,room))
+
+ # Finally sort the rooms by distance
+ rooms.sort(key=lambdatup:tup[0])
+ returnrooms
+
+
+
This gets more serious.
+
+
We have specified coordinates as parameters. We determine a broad range using the distance.
+That is, for each coordinate, we create a list of possible matches. See the example below.
+
We then search for the rooms within this broader range. It gives us a square
+around our location. Some rooms are definitely outside the range. Again, see the example below
+to follow the logic.
+
We filter down the list and sort it by distance from the specified coordinates.
+
+
Notice that we only search starting at step 2. Thus, the Django search doesn’t look and cache all
+objects, just a wider range than what would be really necessary. This method returns a circle of
+coordinates around a specified point. Django looks for a square. What wouldn’t fit in the circle
+is removed at step 3, which is the only part that includes systematic calculation. This method is
+optimized to be quick and efficient.
An example might help. Consider this very simple map (a textual description follows):
+
4ABCD
+3EFGH
+2IJKL
+1MNOP
+ 1234
+
+
+
The X coordinates are given below. The Y coordinates are given on the left. This is a simple
+square with 16 rooms: 4 on each line, 4 lines of them. All the rooms are identified by letters in
+this example: the first line at the top has rooms A to D, the second E to H, the third I to L and
+the fourth M to P. The bottom-left room, X=1 and Y=1, is M. The upper-right room X=4 and Y=4 is D.
+
So let’s say we want to find all the neighbors, distance 1, from the room J. J is at X=2, Y=2.
+
So we use:
+
Room.get_rooms_around(x=2, y=2, z=0, distance=1)
+# we'll assume a z coordinate of 0 for simplicity
+
+
+
+
First, this method gets all the rooms in a square around J. So it gets E F G, I J K, M N O. If
+you want, draw the square around these coordinates to see what’s happening.
+
Next, we browse over this list and check the real distance between J (X=2, Y=2) and the room.
+The four corners of the square are not in this circle. For instance, the distance between J and M
+is not 1. If you draw a circle of center J and radius 1, you’ll notice that the four corners of our
+square (E, G, M and O) are not in this circle. So we remove them.
+
We sort by distance from J.
+
+
So in the end we might obtain something like this:
+
[
+ (0,J),# yes, J is part of this circle after all, with a distance of 0
+ (1,F),
+ (1,I),
+ (1,K),
+ (1,N),
+]
+
+
+
You can try with more examples if you want to see this in action.
Note: This is considered an advanced topic and is mostly of interest to users planning to implement
+their own custom client protocol.
+
A PortalSession is the basic data object representing an
+external
+connection to the Evennia Portal – usually a human player running a mud client
+of some kind. The way they connect (the language the player’s client and Evennia use to talk to
+each other) is called the connection Protocol. The most common such protocol for MUD:s is the
+Telnet protocol. All Portal Sessions are stored and managed by the Portal’s sessionhandler.
+
It’s technically sometimes hard to separate the concept of PortalSession from the concept of
+Protocol since both depend heavily on the other (they are often created as the same class). When
+data flows through this part of the system, this is how it goes
+
# In the Portal
+You<->
+ Protocol+PortalSession<->
+ PortalSessionHandler<->
+ (AMP)<->
+ ServerSessionHandler<->
+ ServerSession<->
+ InputFunc
+
+
+
(See the Message Path for the bigger picture of how data flows through Evennia). The
+parts that needs to be customized to make your own custom protocol is the Protocol+PortalSession
+(which translates between data coming in/out over the wire to/from Evennia internal representation)
+as well as the InputFunc (which handles incoming data).
Evennia has a plugin-system that add the protocol as a new “service” to the application.
+
Take a look at evennia/server/portal/portal.py, notably the sections towards the end of that file.
+These are where the various in-built services like telnet, ssh, webclient etc are added to the
+Portal (there is an equivalent but shorter list in evennia/server/server.py).
+
To add a new service of your own (for example your own custom client protocol) to the Portal or
+Server, look at mygame/server/conf/server_services_plugins and portal_services_plugins. By
+default Evennia will look into these modules to find plugins. If you wanted to have it look for more
+modules, you could do the following:
+
# add to the Server
+ SERVER_SERVICES_PLUGIN_MODULES.append('server.conf.my_server_plugins')
+ # or, if you want to add to the Portal
+ PORTAL_SERVICES_PLUGIN_MODULES.append('server.conf.my_portal_plugins')
+
+
+
When adding a new connection you’ll most likely only need to add new things to the
+PORTAL_SERVICES_PLUGIN_MODULES.
+
This module can contain whatever you need to define your protocol, but it must contain a function
+start_plugin_services(app). This is called by the Portal as part of its upstart. The function
+start_plugin_services must contain all startup code the server need. The app argument is a
+reference to the Portal/Server application itself so the custom service can be added to it. The
+function should not return anything.
+
This is how it looks:
+
# mygame/server/conf/portal_services_plugins.py
+
+ # here the new Portal Twisted protocol is defined
+ classMyOwnFactory(...):
+ [...]
+
+ # some configs
+ MYPROC_ENABLED=True# convenient off-flag to avoid having to edit settings all the time
+ MY_PORT=6666
+
+ defstart_plugin_services(portal):
+ "This is called by the Portal during startup"
+ ifnotMYPROC_ENABLED:
+ return
+ # output to list this with the other services at startup
+ print(" myproc: %s"%MY_PORT)
+
+ # some setup (simple example)
+ factory=MyOwnFactory()
+ my_service=internet.TCPServer(MY_PORT,factory)
+ # all Evennia services must be uniquely named
+ my_service.setName("MyService")
+ # add to the main portal application
+ portal.services.addService(my_service)
+
+
+
Once the module is defined and targeted in settings, just reload the server and your new
+protocol/services should start with the others.
Writing a stable communication protocol from scratch is not something we’ll cover here, it’s no
+trivial task. The good news is that Twisted offers implementations of many common protocols, ready
+for adapting.
+
Writing a protocol implementation in Twisted usually involves creating a class inheriting from an
+already existing Twisted protocol class and from evennia.server.session.Session (multiple
+inheritance), then overloading the methods that particular protocol uses to link them to the
+Evennia-specific inputs.
+
Here’s a example to show the concept:
+
# In module that we'll later add to the system through PORTAL_SERVICE_PLUGIN_MODULES
+
+# pseudo code
+fromtwisted.somethingimportTwistedClient
+# this class is used both for Portal- and Server Sessions
+fromevennia.server.sessionimportSession
+
+fromevennia.server.portal.portalsessionhandlerimportPORTAL_SESSIONS
+
+classMyCustomClient(TwistedClient,Session):
+
+ def__init__(self,*args,**kwargs):
+ super().__init__(*args,**kwargs)
+ self.sessionhandler=PORTAL_SESSIONS
+
+ # these are methods we must know that TwistedClient uses for
+ # communication. Name and arguments could vary for different Twisted protocols
+ defonOpen(self,*args,**kwargs):
+ # let's say this is called when the client first connects
+
+ # we need to init the session and connect to the sessionhandler. The .factory
+ # is available through the Twisted parents
+
+ client_address=self.getClientAddress()# get client address somehow
+
+ self.init_session("mycustom_protocol",client_address,self.factory.sessionhandler)
+ self.sessionhandler.connect(self)
+
+ defonClose(self,reason,*args,**kwargs):
+ # called when the client connection is dropped
+ # link to the Evennia equivalent
+ self.disconnect(reason)
+
+ defonMessage(self,indata,*args,**kwargs):
+ # called with incoming data
+ # convert as needed here
+ self.data_in(data=indata)
+
+ defsendMessage(self,outdata,*args,**kwargs):
+ # called to send data out
+ # modify if needed
+ super().sendMessage(self,outdata,*args,**kwargs)
+
+ # these are Evennia methods. They must all exist and look exactly like this
+ # The above twisted-methods call them and vice-versa. This connects the protocol
+ # the Evennia internals.
+
+ defdisconnect(self,reason=None):
+ """
+ Called when connection closes.
+ This can also be called directly by Evennia when manually closing the connection.
+ Do any cleanups here.
+ """
+ self.sessionhandler.disconnect(self)
+
+ defat_login(self):
+ """
+ Called when this session authenticates by the server (if applicable)
+ """
+
+ defdata_in(self,**kwargs):
+ """
+ Data going into the server should go through this method. It
+ should pass data into `sessionhandler.data_in`. THis will be called
+ by the sessionhandler with the data it gets from the approrpriate
+ send_* method found later in this protocol.
+ """
+ self.sessionhandler.data_in(self,text=kwargs['data'])
+
+ defdata_out(self,**kwargs):
+ """
+ Data going out from the server should go through this method. It should
+ hand off to the protocol's send method, whatever it's called.
+ """
+ # we assume we have a 'text' outputfunc
+ self.onMessage(kwargs['text'])
+
+ # 'outputfuncs' are defined as `send_<outputfunc_name>`. From in-code, they are called
+ # with `msg(outfunc_name=<data>)`.
+
+ defsend_text(self,txt,*args,**kwargs):
+ """
+ Send text, used with e.g. `session.msg(text="foo")`
+ """
+ # we make use of the
+ self.data_out(text=txt)
+
+ defsend_default(self,cmdname,*args,**kwargs):
+ """
+ Handles all outputfuncs without an explicit `send_*` method to handle them.
+ """
+ self.data_out(**{cmdname:str(args)})
+
+
+
+
The principle here is that the Twisted-specific methods are overridden to redirect inputs/outputs to
+the Evennia-specific methods.
To send data out through this protocol, you’d need to get its Session and then you could e.g.
+
session.msg(text="foo")
+
+
+
The message will pass through the system such that the sessionhandler will dig out the session and
+check if it has a send_text method (it has). It will then pass the “foo” into that method, which
+in our case means sending “foo” across the network.
Just because the protocol is there, does not mean Evennia knows what to do with it. An
+Inputfunc must exist to receive it. In the case of the text input exemplified above,
+Evennia alredy handles this input - it will parse it as a Command name followed by its inputs. So
+handle that you need to simply add a cmdset with commands on your receiving Session (and/or the
+Object/Character it is puppeting). If not you may need to add your own Inputfunc (see the
+Inputfunc page for how to do this.
+
These might not be as clear-cut in all protocols, but the principle is there. These four basic
+components - however they are accessed - links to the Portal Session, which is the actual common
+interface between the different low-level protocols and Evennia.
To take two examples, Evennia supports the telnet protocol as well as webclient, via ajax or
+websockets. You’ll find that whereas telnet is a textbook example of a Twisted protocol as seen
+above, the ajax protocol looks quite different due to how it interacts with the
+webserver through long-polling (comet) style requests. All the necessary parts
+mentioned above are still there, but by necessity implemented in very different
+ways.
By default, Evennia’s default channel commands are inspired by MUX. They all
+begin with “c” followed by the action to perform (like “ccreate” or “cdesc”).
+If this default seems strange to you compared to other Evennia commands that
+rely on switches, you might want to check this tutorial out.
+
This tutorial will also give you insight into the workings of the channel system.
+So it may be useful even if you don’t plan to make the exact changes shown here.
Our mission: change the default channel commands to have a different syntax.
+
This tutorial will do the following changes:
+
+
Remove all the default commands to handle channels.
+
Add a + and - command to join and leave a channel. So, assuming there is
+a public channel on your game (most often the case), you could type +public
+to join it and -public to leave it.
+
Group the commands to manipulate channels under the channel name, after a
+switch. For instance, instead of writing cdescpublic=Mypublicchannel,
+you would write public/descMypublicchannel.
+
+
+
I listed removing the default Evennia commands as a first step in the
+process. Actually, we’ll move it at the very bottom of the list, since we
+still want to use them, we might get it wrong and rely on Evennia commands
+for a while longer.
We’ll do the most simple task at first: create two commands, one to join a
+channel, one to leave.
+
+
Why not have them as switches? public/join and public/leave for instance?
+
+
For security reasons, I will hide channels to which the caller is not
+connected. It means that if the caller is not connected to the “public”
+channel, he won’t be able to use the “public” command. This is somewhat
+standard: if we create an administrator-only channel, we don’t want players to
+try (or even know) the channel command. Again, you could design it a different
+way should you want to.
+
First create a file named comms.py in your commands package. It’s
+a rather logical place, since we’ll write different commands to handle
+communication.
+
Okay, let’s add the first command to join a channel:
+
# in commands/comms.py
+fromevennia.utils.searchimportsearch_channel
+fromcommands.commandimportCommand
+
+classCmdConnect(Command):
+ """
+ Connect to a channel.
+ """
+
+ key="+"
+ help_category="Comms"
+ locks="cmd:not pperm(channel_banned)"
+ auto_help=False
+
+ deffunc(self):
+ """Implement the command"""
+ caller=self.caller
+ args=self.args
+ ifnotargs:
+ self.msg("Which channel do you want to connect to?")
+ return
+
+ channelname=self.args
+ channel=search_channel(channelname)
+ ifnotchannel:
+ return
+
+ # Check permissions
+ ifnotchannel.access(caller,'listen'):
+ self.msg("%s: You are not allowed to listen to this channel."%channel.key)
+ return
+
+ # If not connected to the channel, try to connect
+ ifnotchannel.has_connection(caller):
+ ifnotchannel.connect(caller):
+ self.msg("%s: You are not allowed to join this channel."%channel.key)
+ return
+ else:
+ self.msg("You now are connected to the %s channel. "%channel.key.lower())
+ else:
+ self.msg("You already are connected to the %s channel. "%channel.key.lower())
+
+
+
Okay, let’s review this code, but if you’re used to Evennia commands, it shouldn’t be too strange:
+
+
We import search_channel. This is a little helper function that we will use to search for
+channels by name and aliases, found in evennia.utils.search. It’s just more convenient.
+
Our class CmdConnect contains the body of our command to join a channel.
+
Notice the key of this command is simply "+". When you enter +something in the game, it will
+try to find a command key +something. Failing that, it will look at other potential matches.
+Evennia is smart enough to understand that when we type +something, + is the command key and
+something is the command argument. This will, of course, fail if you have a command beginning by
++ conflicting with the CmdConnect key.
+
We have altered some class attributes, like auto_help. If you want to know what they do and
+why they have changed here, you can check the documentation on commands.
+
In the command body, we begin by extracting the channel name. Remember that this name should be
+in the command arguments (that is, in self.args). Following the same example, if a player enters
++something, self.args should contain "something". We use search_channel to see if this
+channel exists.
+
We then check the access level of the channel, to see if the caller can listen to it (not
+necessarily use it to speak, mind you, just listen to others speak, as these are two different locks
+on Evennia).
+
Finally, we connect the caller if he’s not already connected to the channel. We use the
+channel’s connect method to do this. Pretty straightforward eh?
+
+
Now we’ll add a command to leave a channel. It’s almost the same, turned upside down:
+
classCmdDisconnect(Command):
+ """
+ Disconnect from a channel.
+ """
+
+ key="-"
+ help_category="Comms"
+ locks="cmd:not pperm(channel_banned)"
+ auto_help=False
+
+ deffunc(self):
+ """Implement the command"""
+ caller=self.caller
+ args=self.args
+ ifnotargs:
+ self.msg("Which channel do you want to disconnect from?")
+ return
+
+ channelname=self.args
+ channel=search_channel(channelname)
+ ifnotchannel:
+ return
+
+ # If connected to the channel, try to disconnect
+ ifchannel.has_connection(caller):
+ ifnotchannel.disconnect(caller):
+ self.msg("%s: You are not allowed to disconnect from this channel."%channel.key)
+ return
+ else:
+ self.msg("You stop listening to the %s channel. "%channel.key.lower())
+ else:
+ self.msg("You are not connected to the %s channel. "%channel.key.lower())
+
+
+
So far, you shouldn’t have trouble following what this command does: it’s
+pretty much the same as the CmdConnect class in logic, though it accomplishes
+the opposite. If you are connected to the channel public you could
+disconnect from it using -public. Remember, you can use channel aliases too
+(+pub and -pub will also work, assuming you have the alias pub on the
+public channel).
+
It’s time to test this code, and to do so, you will need to add these two
+commands. Here is a good time to say it: by default, Evennia connects accounts
+to channels. Some other games (usually with a higher multisession mode) will
+want to connect characters instead of accounts, so that several characters in
+the same account can be connected to various channels. You can definitely add
+these commands either in the AccountCmdSet or CharacterCmdSet, the caller
+will be different and the command will add or remove accounts of characters.
+If you decide to install these commands on the CharacterCmdSet, you might
+have to disconnect your superuser account (account #1) from the channel before
+joining it with your characters, as Evennia tends to subscribe all accounts
+automatically if you don’t tell it otherwise.
+
So here’s an example of how to add these commands into your AccountCmdSet.
+Edit the file commands/default_cmdsets.py to change a few things:
+
# In commands/default_cmdsets.py
+fromevenniaimportdefault_cmds
+fromcommands.commsimportCmdConnect,CmdDisconnect
+
+
+# ... Skip to the AccountCmdSet class ...
+
+classAccountCmdSet(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"
+
+ defat_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ super().at_cmdset_creation()
+
+ # Channel commands
+ self.add(CmdConnect())
+ self.add(CmdDisconnect())
+
+
+
Save, reload your game, and you should be able to use +public and -public
+now!
It’s time to dive a little deeper into channel processing. What happens in
+Evennia when a player enters publicHelloeverybody!?
+
Like exits, channels are a particular command that Evennia automatically
+creates and attaches to individual channels. So when you enter publicmessage in your game, Evennia calls the public command.
+
+
But I didn’t add any public command…
+
+
Evennia will just create these commands automatically based on the existing
+channels. The base command is the command we’ll need to edit.
+
+
Why edit it? It works just fine to talk.
+
+
Unfortunately, if we want to add switches to our channel names, we’ll have to
+edit this command. It’s not too hard, however, we’ll just start writing a
+standard command with minor twitches.
# In commands/comms.py
+classChannelCommand(Command):
+ """
+ {channelkey} channel
+
+ {channeldesc}
+
+ Usage:
+ {lower_channelkey} <message>
+ {lower_channelkey}/history [start]
+ {lower_channelkey}/me <message>
+ {lower_channelkey}/who
+
+ Switch:
+ history: View 20 previous messages, either from the end or
+ from <start> number of messages from the end.
+ me: Perform an emote on this channel.
+ who: View who is connected to this channel.
+
+ Example:
+ {lower_channelkey} Hello World!
+ {lower_channelkey}/history
+ {lower_channelkey}/history 30
+ {lower_channelkey}/me grins.
+ {lower_channelkey}/who
+ """
+ # note that channeldesc and lower_channelkey will be filled
+ # automatically by ChannelHandler
+
+ # this flag is what identifies this cmd as a channel cmd
+ # and branches off to the system send-to-channel command
+ # (which is customizable by admin)
+ is_channel=True
+ key="general"
+ help_category="Channel Names"
+ obj=None
+ arg_regex=""
+
+
+
There are some differences here compared to most common commands.
+
+
There is something disconcerting in the class docstring. Some information is
+between curly braces. This is a format-style which is only used for channel
+commands. {channelkey} will be replaced by the actual channel key (like
+public). {channeldesc} will be replaced by the channel description (like
+“public channel”). And {lower_channelkey}.
+
We have set is_channel to True in the command class variables. You
+shouldn’t worry too much about that: it just tells Evennia this is a special
+command just for channels.
+
key is a bit misleading because it will be replaced eventually. So we
+could set it to virtually anything.
+
The obj class variable is another one we won’t detail right now.
+
arg_regex is important: the default arg_regex in the channel command will
+forbid to use switches (a slash just after the channel name is not allowed).
+That’s why we enforce it here, we allow any syntax.
+
+
+
What will become of this command?
+
+
Well, when we’ll be through with it, and once we’ll add it as the default
+command to handle channels, Evennia will create one per existing channel. For
+instance, the public channel will receive one command of this class, with key
+set to public and aliases set to the channel aliases (like ['pub']).
+
+
Can I see it work?
+
+
Not just yet, there’s still a lot of code needed.
+
Okay we have the command structure but it’s rather empty.
Reading the comments we see that the channel handler will send the command in a
+strange way: a string with the channel name, a colon and the actual message
+entered by the player. So if the player enters “public hello”, the command
+args will contain "public:hello". You can look at the way the channel name
+and message are parsed, this can be used in a lot of different commands.
+
Next we check if there’s any switch, that is, if the message starts with a
+slash. This would be the case if a player entered public/mejumpsupanddown, for instance. If there is a switch, we save it in self.switch. We
+alter self.args at the end to contain a tuple with two values: the channel
+name, and the message (if a switch was used, notice that the switch will be
+stored in self.switch, not in the second element of self.args).
Finally, let’s see the func method in the command class. It will have to
+handle switches and also the raw message to send if no switch was used.
+
# ...
+ deffunc(self):
+ """
+ Create a new message and send it to channel, using
+ the already formatted input.
+ """
+ channelkey,msg=self.args
+ caller=self.caller
+ channel=ChannelDB.objects.get_channel(channelkey)
+
+ # Check that the channel exists
+ ifnotchannel:
+ self.msg(_("Channel '%s' not found.")%channelkey)
+ return
+
+ # Check that the caller is connected
+ ifnotchannel.has_connection(caller):
+ string="You are not connected to channel '%s'."
+ self.msg(string%channelkey)
+ return
+
+ # Check that the caller has send access
+ ifnotchannel.access(caller,'send'):
+ string="You are not permitted to send to channel '%s'."
+ self.msg(string%channelkey)
+ return
+
+ # Handle the various switches
+ ifself.switch=="me":
+ ifnotmsg:
+ self.msg("What do you want to do on this channel?")
+ else:
+ msg="{}{}".format(caller.key,msg)
+ channel.msg(msg,online=True)
+ elifself.switch:
+ self.msg("{}: Invalid switch {}.".format(channel.key,self.switch))
+ elifnotmsg:
+ self.msg("Say what?")
+ else:
+ ifcallerinchannel.mutelist:
+ self.msg("You currently have %s muted."%channel)
+ return
+ channel.msg(msg,senders=self.caller,online=True)
+
+
+
+
First of all, we try to get the channel object from the channel name we have
+in the self.args tuple. We use ChannelDB.objects.get_channel this time
+because we know the channel name isn’t an alias (that was part of the deal,
+channelname in the parse method contains a command key).
+
We check that the channel does exist.
+
We then check that the caller is connected to the channel. Remember, if the
+caller isn’t connected, we shouldn’t allow him to use this command (that
+includes the switches on channels).
+
We then check that the caller has access to the channel’s send lock. This
+time, we make sure the caller can send messages to the channel, no matter what
+operation he’s trying to perform.
+
Finally we handle switches. We try only one switch: me. This switch would
+be used if a player entered public/mejumpsupanddown (to do a channel
+emote).
+
We handle the case where the switch is unknown and where there’s no switch
+(the player simply wants to talk on this channel).
+
+
The good news: The code is not too complicated by itself. The bad news is that
+this is just an abridged version of the code. If you want to handle all the
+switches mentioned in the command help, you will have more code to write. This
+is left as an exercise.
It’s almost done, but we need to add a method in this command class that isn’t
+often used. I won’t detail it’s usage too much, just know that Evennia will use
+it and will get angry if you don’t add it. So at the end of your class, just
+add:
+
# ...
+ defget_extra_info(self,caller,**kwargs):
+ """
+ Let users know that this command is for communicating on a channel.
+
+ Args:
+ caller (TypedObject): A Character or Account who has entered an ambiguous command.
+
+ Returns:
+ A string with identifying information to disambiguate the object, conventionally with a
+preceding space.
+ """
+ return" (channel)"
+
Contrary to most Evennia commands, we won’t add our ChannelCommand to a
+CmdSet. Instead we need to tell Evennia that it should use the command we
+just created instead of its default channel-command.
+
In your server/conf/settings.py file, add a new setting:
Then you can reload your game. Try to type publichello and public/mejumpsupanddown. Don’t forget to enter helppublic to see if your command has
+truly been added.
That was some adventure! And there’s still things to do! But hopefully, this
+tutorial will have helped you in designing your own channel system. Here are a
+few things to do:
+
+
Add more switches to handle various actions, like changing the description of
+a channel for instance, or listing the connected participants.
+
Remove the default Evennia commands to handle channels.
+
Alter the behavior of the channel system so it better aligns with what you
+want to do.
+
+
As a special bonus, you can find a full, working example of a communication
+system similar to the one I’ve shown you: this is a working example, it
+integrates all switches and does ever some extra checking, but it’s also very
+close from the code I’ve provided here. Notice, however, that this resource is
+external to Evennia and not maintained by anyone but the original author of
+this article.
Sometimes, an error is not trivial to resolve. A few simple print statements is not enough to find
+the cause of the issue. Running a debugger can then be very helpful and save a lot of time.
+Debugging
+means running Evennia under control of a special debugger program. This allows you to stop the
+action at a given point, view the current state and step forward through the program to see how its
+logic works.
+
Evennia natively supports these debuggers:
+
+
Pdb is a part of the Python distribution and
+available out-of-the-box.
+
PuDB is a third-party debugger that has a slightly more
+‘graphical’, curses-based user interface than pdb. It is installed with pipinstallpudb.
To run Evennia with the debugger, follow these steps:
+
+
Find the point in the code where you want to have more insight. Add the following line at that
+point.
+
fromevenniaimportset_trace;set_trace()
+
+
+
+
(Re-)start Evennia in interactive (foreground) mode with evenniaistart. This is important -
+without this step the debugger will not start correctly - it will start in this interactive
+terminal.
+
Perform the steps that will trigger the line where you added the set_trace() call. The debugger
+will start in the terminal from which Evennia was interactively started.
+
+
The evennia.set_trace function takes the following arguments:
Here, debugger is one of pdb, pudb or auto. If auto, use pudb if available, otherwise
+use pdb. The term_size tuple sets the viewport size for pudb only (it’s ignored by pdb).
The debugger is useful in different cases, but to begin with, let’s see it working in a command.
+Add the following test command (which has a range of deliberate errors) and also add it to your
+default cmdset. Then restart Evennia in interactive mode with evenniaistart.
+
# In file commands/command.py
+
+
+classCmdTest(Command):
+
+ """
+ A test command just to test pdb.
+
+ Usage:
+ test
+
+ """
+
+ key="test"
+
+ deffunc(self):
+ fromevenniaimportset_trace;set_trace()# <--- start of debugger
+ obj=self.search(self.args)
+ self.msg("You've found {}.".format(obj.get_display_name()))
+
+
+
+
If you type test in your game, everything will freeze. You won’t get any feedback from the game,
+and you won’t be able to enter any command (nor anyone else). It’s because the debugger has started
+in your console, and you will find it here. Below is an example with pdb.
pdb notes where it has stopped execution and, what line is about to be executed (in our case, obj=self.search(self.args)), and ask what you would like to do.
When you have the pdb prompt (Pdb), you can type in different commands to explore the code. The
+first one you should know is list (you can type l for short):
+
(Pdb)l
+ 43
+ 44key="test"
+ 45
+ 46deffunc(self):
+ 47fromevenniaimportset_trace;set_trace()# <--- start of debugger
+ 48->obj=self.search(self.args)
+ 49self.msg("You've found {}.".format(obj.get_display_name()))
+ 50
+ 51# -------------------------------------------------------------
+ 52#
+ 53# The default commands inherit from
+(Pdb)
+
+
+
Okay, this didn’t do anything spectacular, but when you become more confident with pdb and find
+yourself in lots of different files, you sometimes need to see what’s around in code. Notice that
+there is a little arrow (->) before the line that is about to be executed.
+
This is important: about to be, not has just been. You need to tell pdb to go on (we’ll
+soon see how).
pdb allows you to examine variables (or really, to run any Python instruction). It is very useful
+to know the values of variables at a specific line. To see a variable, just type its name (as if
+you were in the Python interpreter:
That figures, since at this point, we haven’t created the variable yet.
+
+
Examining variable in this way is quite powerful. You can even run Python code and keep on
+executing, which can help to check that your fix is actually working when you have identified an
+error. If you have variable names that will conflict with pdb commands (like a list
+variable), you can prefix your variable with !, to tell pdb that what follows is Python code.
It’s time we asked pdb to execute the current line. To do so, use the next command. You can
+shorten it by just typing n:
+
(Pdb)n
+AttributeError:"'CmdTest' object has no attribute 'search'"
+>.../mygame/commands/command.py(79)func()
+->obj=self.search(self.args)
+(Pdb)
+
+
+
Pdb is complaining that you try to call the search method on a command… whereas there’s no
+search method on commands. The character executing the command is in self.caller, so we might
+change our line:
pdb is waiting to execute the same instruction… it provoked an error but it’s ready to try
+again, just in case. We have fixed it in theory, but we need to reload, so we need to enter a
+command. To tell pdb to terminate and keep on running the program, use the continue (or c)
+command:
+
(Pdb)c
+...
+
+
+
You see an error being caught, that’s the error we have fixed… or hope to have. Let’s reload the
+game and try again. You need to run evenniaistart again and then run test to get into the
+command again.
n is useful, but it will avoid stepping inside of functions if it can. But most of the time, when
+we have an error we don’t understand, it’s because we use functions or methods in a way that wasn’t
+intended by the developer of the API. Perhaps using wrong arguments, or calling the function in a
+situation that would cause a bug. When we have a line in the debugger that calls a function or
+method, we can “step” to examine it further. For instance, in the previous example, when pdb was
+about to execute obj=self.caller.search(self.args), we may want to see what happens inside of
+the search method.
+
To do so, use the step (or s) command. This command will show you the definition of the
+function/method and you can then use n as before to see it line-by-line. In our little example,
+stepping through a function or method isn’t that useful, but when you have an impressive set of
+commands, functions and so on, it might really be handy to examine some feature and make sure they
+operate as planned.
PuDB and Pdb share the same commands. The only real difference is how it’s presented. The look
+command is not needed much in pudb since it displays the code directly in its user interface.
+
+
+
Pdb/PuDB command
+
To do what
+
+
+
+
list (or l)
+
List the lines around the point of execution (not needed for pudb, it will show
+
+
this directly).
+
+
+
print (or p)
+
Display one or several variables.
+
+
!
+
Run Python code (using a ! is often optional).
+
+
continue (or c)
+
Continue execution and terminate the debugger for this time.
+
+
next (or n)
+
Execute the current line and goes to the next one.
+
+
step (or s)
+
Step inside of a function or method to examine it.
+
+
<RETURN>
+
Repeat the last command (don’t type n repeatedly, just type it once and then press
The full set of default Evennia commands currently contains 98 commands in 9 source
+files. Our policy for adding default commands is outlined here. The
+Commands documentation explains how Commands work as well as make new or customize
+existing ones. Note that this page is auto-generated. Report problems to the issue
+tracker.
+
+
Note
+
Some game-states adds their own Commands which are not listed here. Examples include editing a text
+with EvEditor, flipping pages in EvMore or using the
+Batch-Processor’s interactive mode.
Evennia allows for exits to have any name. The command “kitchen” is a valid exit name as well as
+“jump out the window” or “north”. An exit actually consists of two parts: an Exit Object
+and an Exit Command stored on said exit object. The command has the same key and aliases
+as the object, which is why you can see the exit in the room and just write its name to traverse it.
+
If you try to enter the name of a non-existing exit, it is thus the same as trying a non-exising
+command; Evennia doesn’t care about the difference:
+
> jump out the window
+ Command 'jump out the window' is not available. Type "help" for help.
+
+
+
Many games don’t need this type of freedom however. They define only the cardinal directions as
+valid exit names (Evennia’s @tunnel command also offers this functionality). In this case, the
+error starts to look less logical:
+
> west
+ Command 'west' is not available. Maybe you meant "@set" or "@reset"?
+
+
+
Since we for our particular game know that west is an exit direction, it would be better if the
+error message just told us that we couldn’t go there.
To solve this you need to be aware of how to write and add new commands.
+What you need to do is to create new commands for all directions you want to support in your game.
+In this example all we’ll do is echo an error message, but you could certainly consider more
+advanced uses. You add these commands to the default command set. Here is an example of such a set
+of commands:
+
# for example in a file mygame/commands/movecommands.py
+
+fromevenniaimportdefault_cmds
+
+classCmdExitError(default_cmds.MuxCommand):
+ "Parent class for all exit-errors."
+ locks="cmd:all()"
+ arg_regex=r"\s|$"
+ auto_help=False
+ deffunc(self):
+ "returns the error"
+ self.caller.msg("You cannot move %s."%self.key)
+
+classCmdExitErrorNorth(CmdExitError):
+ key="north"
+ aliases=["n"]
+
+classCmdExitErrorEast(CmdExitError):
+ key="east"
+ aliases=["e"]
+
+classCmdExitErrorSouth(CmdExitError):
+ key="south"
+ aliases=["s"]
+
+classCmdExitErrorWest(CmdExitError):
+ key="west"
+ aliases=["w"]
+
+
+
Make sure to add the directional commands (not their parent) to the CharacterCmdSet class in
+mygame/commands/default_cmdsets.py:
After a @reload these commands (assuming you don’t get any errors - check your log) will be
+loaded. What happens henceforth is that if you are in a room with an Exitobject (let’s say it’s
+“north”), the proper Exit-command will overload your error command (also named “north”). But if you
+enter an direction without having a matching exit for it, you will fallback to your default error
+commands:
+
> east
+ You cannot move east.
+
+
+
Further expansions by the exit system (including manipulating the way the Exit command itself is
+created) can be done by modifying the Exit typeclass directly.
The anwer is that this would not work and understanding why is important in order to not be
+confused when working with commands and command sets.
+
The reason it doesn’t work is because Evennia’s command system compares commands both
+by key and by aliases. If either of those match, the two commands are considered identical
+as far as cmdset merging system is concerned.
+
So the above example would work fine as long as there were no Exits at all in the room. But what
+happens when we enter a room with an exit “north”? The Exit’s cmdset is merged onto the default one,
+and since there is an alias match, the system determines our CmdExitError to be identical. It is
+thus overloaded by the Exit command (which also correctly defaults to a higher priority). The result
+is that you can go through the north exit normally but none of the error messages for the other
+directions are available since the single error command was completely overloaded by the single
+matching “north” exit-command.
Next tutorial: [adding a voice-operated elevator with events](A-voice-operated-elevator-using-
+events).
+
+
This tutorial will walk you through the steps to create several dialogues with characters, using the
+in-game Python
+system.
+This tutorial assumes the in-game Python system is installed in your game. If it isn’t, you can
+follow the installation steps given in the documentation on in-game
+Python, and
+come back on this tutorial once the system is installed. You do not need to read the entire
+documentation, it’s a good reference, but not the easiest way to learn about it. Hence these
+tutorials.
+
The in-game Python system allows to run code on individual objects in some situations. You don’t
+have to modify the source code to add these features, past the installation. The entire system
+makes it easy to add specific features to some objects, but not all. This is why it can be very
+useful to create a dialogue system taking advantage of the in-game Python system.
+
+
What will we try to do?
+
+
In this tutorial, we are going to create a basic dialogue to have several characters automatically
+respond to specific messages said by others.
This will create a merchant in the room where you currently are. It doesn’t have anything, like a
+description, you can decorate it a bit if you like.
+
As said above, the in-game Python system consists in linking objects with arbitrary code. This code
+will be executed in some circumstances. Here, the circumstance is “when someone says something in
+the same room”, and might be more specific like “when someone says hello”. We’ll decide what code
+to run (we’ll actually type the code in-game). Using the vocabulary of the in-game Python system,
+we’ll create a callback: a callback is just a set of lines of code that will run under some
+conditions.
+
You can have an overview of every “conditions” in which callbacks can be created using the @call
+command (short for @callback). You need to give it an object as argument. Here for instance, we
+could do:
+
@call a merchant
+
+
+
You should see a table with three columns, showing the list of events existing on our newly-created
+merchant. There are quite a lot of them, as it is, althougn no line of code has been set yet. For
+our system, you might be more interested by the line describing the say event:
+
| say | 0 (0) | After another character has said something in |
+| | | the character's room. |
+
+
+
We’ll create a callback on the say event, called when we say “hello” in the merchant’s room:
+
@call/add a merchant = say hello
+
+
+
Before seeing what this command displays, let’s see the command syntax itself:
+
+
@call is the command name, /add is a switch. You can read the help of the command to get the
+help of available switches and a brief overview of syntax.
+
We then enter the object’s name, here “a merchant”. You can enter the ID too (“#3” in my case),
+which is useful to edit the object when you’re not in the same room. You can even enter part of the
+name, as usual.
+
An equal sign, a simple separator.
+
The event’s name. Here, it’s “say”. The available events are displayed when you use @call
+without switch.
+
After a space, we enter the conditions in which this callback should be called. Here, the
+conditions represent what the other character should say. We enter “hello”. Meaning that if
+someone says something containing “hello” in the room, the callback we are now creating will be
+called.
+
+
When you enter this command, you should see something like this:
That’s some list of information. What’s most important to us now is:
+
+
The “say” event is called whenever someone else speaks in the room.
+
We can set callbacks to fire when specific keywords are present in the phrase by putting them as
+additional parameters. Here we have set this parameter to “hello”. We can have several keywords
+separated by a comma (we’ll see this in more details later).
+
We have three default variables we can use in this callback: speaker which contains the
+character who speaks, character which contains the character who’s modified by the in-game Python
+system (here, or merchant), and message which contains the spoken phrase.
+
+
This concept of variables is important. If it makes things more simple to you, think of them as
+parameters in a function: they can be used inside of the function body because they have been set
+when the function was called.
+
This command has opened an editor where we can type our Python code.
character.location.msg_contents("{character} shrugs and says: 'well, yes, hello to you!'",
+mapping=dict(character=character))
+
+
+
Once you have entered this line, you can type :wq to save the editor and quit it.
+
And now if you use the “say” command with a message containing “hello”:
+
Yousay,"Hello sir merchant!"
+amerchant(#3) shrugs and says: 'well, yes, hello to you!'
+
+
+
If you say something that doesn’t contain “hello”, our callback won’t execute.
+
In summary:
+
+
When we say something in the room, using the “say” command, the “say” event of all characters
+(except us) is called.
+
The in-game Python system looks at what we have said, and checks whether one of our callbacks in
+the “say” event contains a keyword that we have spoken.
+
If so, call it, defining the event variables as we have seen.
+
The callback is then executed as normal Python code. Here we have called the msg_contents
+method on the character’s location (probably a room) to display a message to the entire room. We
+have also used mapping to easily display the character’s name. This is not specific to the in-game
+Python system. If you feel overwhelmed by the code we’ve used, just shorten it and use something
+more simple, for instance:
So far, we have only set one line in our callbacks. Which is useful, but we often need more. For
+an entire dialogue, you might want to do a bit more than that.
+
@call/add merchant = say bandit, bandits
+
+
+
And in the editor you can paste the following lines:
+
character.location.msg_contents("{character} says: 'Bandits he?'",
+mapping=dict(character=character))
+character.location.msg_contents("{character} scratches his head, considering.",
+mapping=dict(character=character))
+character.location.msg_contents("{character} whispers: 'Aye, saw some of them, north from here. No
+troubleo' mine, but...'", mapping=dict(character=character))
+speaker.msg("{character} looks at you more
+closely.".format(character=character.get_display_name(speaker)))
+speaker.msg("{character} continues in a low voice: 'Ain't my place to say, but if you need to find
+'em, they'reencampedsomedistanceawayfromtheroad,Iguessnearacaveor
+something.'.".format(character=character.get_display_name(speaker)))
+
+
+
Now try to ask the merchant about bandits:
+
Yousay,"have you seen bandits?"
+amerchant(#3) says: 'Bandits he?'
+amerchant(#3) scratches his head, considering.
+amerchant(#3) whispers: 'Aye, saw some of them, north from here. No trouble o' mine, but...'
+amerchant(#3) looks at you more closely.
+amerchant(#3) continues in a low voice: 'Ain't my place to say, but if you need to find 'em,
+they're encamped some distance away from the road, I guess near a cave or something.'.
+
+
+
Notice here that the first lines of dialogue are spoken to the entire room, but then the merchant is
+talking directly to the speaker, and only the speaker hears it. There’s no real limit to what you
+can do with this.
+
+
You can set a mood system, storing attributes in the NPC itself to tell you in what mood he is,
+which will influence the information he will give… perhaps the accuracy of it as well.
+
You can add random phrases spoken in some context.
+
You can use other actions (you’re not limited to having the merchant say something, you can ask
+him to move, gives you something, attack if you have a combat system, or whatever else).
+
The callbacks are in pure Python, so you can write conditions or loops.
+
You can add in “pauses” between some instructions using chained events. This tutorial won’t
+describe how to do that however. You already have a lot to play with.
Q: can I create several characters who would answer to specific dialogue?
+
A: of course. Te in-game Python system is so powerful because you can set unique code for
+various objects. You can have several characters answering to different things. You can even have
+different characters in the room answering to greetings. All callbacks will be executed one after
+another.
+
Q: can I have two characters answering to the same dialogue in exactly the same way?
+
A: It’s possible but not so easy to do. Usually, event grouping is set in code, and depends
+on different games. However, if it is for some infrequent occurrences, it’s easy to do using
+chained events.
+
Q: is it possible to deploy callbacks on all characters sharing the same prototype?
+
A: not out of the box. This depends on individual settings in code. One can imagine that all
+characters of some type would share some events, but this is game-specific. Rooms of the same zone
+could share the same events as well. It is possible to do but requires modification of the source
+code.
+
Next tutorial: [adding a voice-operated elevator with events](A-voice-operated-elevator-using-
+events).
The game directory is created with evennia--init<name>. In the Evennia documentation we always
+assume it’s called mygame. Apart from the server/ subfolder within, you could reorganize this
+folder if you preferred a different code structure for your game.
server/ - The structure of this folder should not change since Evennia expects it.
+
+
conf/ - All
+server configuration files sits here. The most important file is settings.py.
+
logs/ - Portal log files are stored here (Server is logging to the terminal by default)
+
+
+
typeclasses/ - this folder contains empty templates for overloading default game entities of
+Evennia. Evennia will automatically use the changes in those templates for the game entities it
+creates.
world/ - this is a “miscellaneous” folder holding everything related to the world you are
+building, such as build scripts and rules modules that don’t fit with one of the other folders.
If you cloned the GIT repo following the instructions, you will have a folder named evennia. The
+top level of it contains Python package specific stuff such as a readme file, setup.py etc. It
+also has two subfoldersbin/ and evennia/ (again).
+
The bin/ directory holds OS-specific binaries that will be used when installing Evennia with pip
+as per the Getting started instructions. The library itself is in the evennia
+subfolder. From your code you will access this subfolder simply by importevennia.
+
+
evennia
+
+
__init__.py - The “flat API” of Evennia resides here.
settings_default.py - Root settings of Evennia. Copy settings
+from here to mygame/server/settings.py file.
+
typeclasses/ - Abstract classes for the typeclass storage and database system.
+
utils/ - Various miscellaneous useful coding resources.
+
web/ - Web resources and webserver. Partly copied into game directory on
+initialization.
+
+
+
+
All directories contain files ending in .py. These are Python modules and are the basic units of
+Python code. The roots of directories also have (usually empty) files named __init__.py. These are
+required by Python so as to be able to find and import modules in other directories. When you have
+run Evennia at least once you will find that there will also be .pyc files appearing, these are
+pre-compiled binary versions of the .py files to speed up execution.
+
The root of the evennia folder has an __init__.py file containing the “flat API”.
+This holds shortcuts to various subfolders in the evennia library. It is provided to make it easier
+to find things; it allows you to just import evennia and access things from that rather than
+having to import from their actual locations inside the source tree.
This is a whitepage for free discussion about the wiki docs and refactorings needed.
+
Note that this is not a forum. To keep things clean, each opinion text should ideally present a
+clear argument or lay out a suggestion. Asking for clarification and any side-discussions should be
+held in chat or forum.
This is how to make a discussion entry for the whitepage. Use any markdown formatting you need. Also
+remember to copy your work to the clipboard before saving the page since if someone else edited the
+page since you started, you’ll have to reload and write again.
I think it would be useful for the pages that explain how to use various features of Evennia to
+have explicit and easily visible links to the respective API entry or entries. Some pages do, but
+not all. I imagine this as a single entry at the top of the page […].
It would help me (and probably a couple of others) if there is a way to show the file path where a
+particular thing exists. Maybe up under the ‘last edited’ line we could have a line like:
+evennia/locks/lockhandler.py
+
+
This would help in development to quickly refer to where a resource is located.
Batch Code should have a link in the developer area. It is currently only
+listed in the tutorials section as an afterthought to a tutorial title.
+
+
In regards to the general structure of each wiki page: I’d like to see a table of contents at the
+top of each one, so that it can be quickly navigated and is immediately apparent what sections are
+covered on the page. Similar to the current Getting Started page.
+
+
The structuring of the page should also include a quick reference cheatsheet for certain aspects.
+Such as Tags including a quick reference section at the top that lists an example of every
+available method you can use in a clear and consistent format, along with a comment. Readers
+shouldn’t have to decipher the article to gather such basic information and it should instead be
+available at first glance.
+
Example of a quick reference:
+
Tags
+
# Add a tag.
+obj.tags.add("label")
+
+# Remove a tag.
+obj.tags.remove("label")
+
+# Remove all tags.
+obj.tags.clear()
+
+# Search for a tag. Evennia must be imported first.
+store_result=evennia.search_tag("label")
+
+# Return a list of all tags.
+obj.tags.all()
+
+
+
Aliases
+
# Add an alias.
+obj.aliases.add("label")
+
+ETC...
+
+
+
+
In regards to comment structure, I often find that smushing together lines with comments to be too
+obscure. White space should be used to clearly delineate what information the comment is for. I
+understand that the current format is that a comment references whatever is below it, but newbies
+may not know that until they realize it.
If I want to find information on the correct syntax for is_typeclass(), here’s what I do:
+
+
Pop over to the wiki. Okay, this is a developer functionality. Let’s try that.
+
Ctrl+F on Developer page. No results.
+
Ctrl+F on API page. No results. Ctrl+F on Flat API page. No results
+
Ctrl+F on utils page. No results.
+
Ctrl+F on utils.utils page. No results.
+
Ctrl+F in my IDE. Results.
+
Fortunately, there’s only one result for def is_typeclass. If this was at_look, there would be
+several results, and I’d have to go through each of those individually, and most of them would just
+call return_appearance
+
+
An important part of a refactor, in my opinion, is separating out the “Tutorials” from the
+“Reference” documentation.
An often desired feature in a MUD is to show an in-game map to help navigation. The Static in-game
+map tutorial solves this by creating a static map, meaning the map is pre-
+drawn once and for all - the rooms are then created to match that map. When walking around, parts of
+the static map is then cut out and displayed next to the room description.
+
In this tutorial we’ll instead do it the other way around; We will dynamically draw the map based on
+the relationships we find between already existing rooms.
There are at least two requirements needed for this tutorial to work.
+
+
The structure of your mud has to follow a logical layout. Evennia supports the layout of your
+world to be ‘logically’ impossible with rooms looping to themselves or exits leading to the other
+side of the map. Exits can also be named anything, from “jumping out the window” to “into the fifth
+dimension”. This tutorial assumes you can only move in the cardinal directions (N, E, S and W).
+
Rooms must be connected and linked together for the map to be generated correctly. Vanilla
+Evennia comes with a admin command tunnel that allows a
+user to create rooms in the cardinal directions, but additional work is needed to assure that rooms
+are connected. For example, if you tunneleast and then immediately do tunnelwest you’ll find
+that you have created two completely stand-alone rooms. So care is needed if you want to create a
+“logical” layout. In this tutorial we assume you have such a grid of rooms that we can generate the
+map from.
Before getting into the code, it is beneficial to understand and conceptualize how this is going to
+work. The idea is analogous to a worm that starts at your current position. It chooses a direction
+and ‘walks’ outward from it, mapping its route as it goes. Once it has traveled a pre-set distance
+it stops and starts over in another direction. An important note is that we want a system which is
+easily callable and not too complicated. Therefore we will wrap this entire code into a custom
+Python class (not a typeclass as this doesn’t use any core objects from evennia itself).
+
We are going to create something that displays like this when you type ‘look’:
First we must define the components for displaying the map. For the “worm” to know what symbol to
+draw on the map we will have it check an Attribute on the room it visits called sector_type. For
+this tutorial we understand two symbols - a normal room and the room with us in it. We also define a
+fallback symbol for rooms without said Attribute - that way the map will still work even if we
+didn’t prepare the room correctly. Assuming your game folder is named mygame, we create this code
+in mygame/world/map.py.
+
# in mygame/world/map.py
+
+# the symbol is identified with a key "sector_type" on the
+# Room. Keys None and "you" must always exist.
+SYMBOLS={None:' . ',# for rooms without sector_type Attribute
+ 'you':'[@]',
+ 'SECT_INSIDE':'[.]'}
+
+
+
Since trying to access an unset Attribute returns None, this means rooms without the sector_type
+Atttribute will show as .. Next we start building the custom class Map. It will hold all
+methods we need.
self.caller is normally your Character object, the one using the map.
+
self.max_width/length determine the max width and length of the map that will be generated. Note
+that it’s important that these variables are set to odd numbers to make sure the display area has
+a center point.
+
self.worm_has_mapped is building off the worm analogy above. This dictionary will store all
+rooms the “worm” has mapped as well as its relative position within the grid. This is the most
+important variable as it acts as a ‘checker’ and ‘address book’ that is able to tell us where the
+worm has been and what it has mapped so far.
+
self.curX/Y are coordinates representing the worm’s current location on the grid.
+
+
Before any sort of mapping can actually be done we need to create an empty display area and do some
+sanity checks on it by using the following methods.
+
# in mygame/world/map.py
+
+classMap(object):
+ # [... continued]
+
+ defcreate_grid(self):
+ # This method simply creates an empty grid/display area
+ # with the specified variables from __init__(self):
+ board=[]
+ forrowinrange(self.max_width):
+ board.append([])
+ forcolumninrange(self.max_length):
+ board[row].append(' ')
+ returnboard
+
+ defcheck_grid(self):
+ # this method simply checks the grid to make sure
+ # that both max_l and max_w are odd numbers.
+ returnTrueifself.max_length%2!=0orself.max_width%2!=0\
+ elseFalse
+
+
+
Before we can set our worm on its way, we need to know some of the computer science behind all this
+called ‘Graph Traversing’. In Pseudo code what we are trying to accomplish is this:
+
# pseudo code
+
+defdraw_room_on_map(room,max_distance):
+ self.draw(room)
+
+ ifmax_distance==0:
+ return
+
+ forexitinroom.exits:
+ ifself.has_drawn(exit.destination):
+ # skip drawing if we already visited the destination
+ continue
+ else:
+ # first time here!
+ self.draw_room_on_map(exit.destination,max_distance-1)
+
+
+
The beauty of Python is that our actual code of doing this doesn’t differ much if at all from this
+Pseudo code example.
+
+
max_distance is a variable indicating to our Worm how many rooms AWAY from your current location
+will it map. Obviously the larger the number the more time it will take if your current location has
+many many rooms around you.
+
+
The first hurdle here is what value to use for ‘max_distance’. There is no reason for the worm to
+travel further than what is actually displayed to you. For example, if your current location is
+placed in the center of a display area of size max_length=max_width=9, then the worm need only
+go 4 spaces in either direction:
+
[.][.][.][.][@][.][.][.][.]
+ 432101234
+
+
+
The max_distance can be set dynamically based on the size of the display area. As your width/length
+changes it becomes a simple algebraic linear relationship which is simply max_distance=(min(max_width,max_length)-1)/2.
self.draw_room_on_map(self,room,max_distance) - the main method that ties it all together.`
+
+
Now that we know which methods we need, let’s refine our initial __init__(self) to pass some
+conditional statements and set it up to start building the display.
+
#mygame/world/map.py
+
+classMap(object):
+
+ def__init__(self,caller,max_width=9,max_length=9):
+ self.caller=caller
+ self.max_width=max_width
+ self.max_length=max_length
+ self.worm_has_mapped={}
+ self.curX=None
+ self.curY=None
+
+ ifself.check_grid():
+ # we have to store the grid into a variable
+ self.grid=self.create_grid()
+ # we use the algebraic relationship
+ self.draw_room_on_map(caller.location,
+ ((min(max_width,max_length)-1)/2)
+
+
+
+
Here we check to see if the parameters for the grid are okay, then we create an empty canvas and map
+our initial location as the first room!
+
As mentioned above, the code for the self.draw_room_on_map() is not much different than the Pseudo
+code. The method is shown below:
+
# in mygame/world/map.py, in the Map class
+
+defdraw_room_on_map(self,room,max_distance):
+ self.draw(room)
+
+ ifmax_distance==0:
+ return
+
+ forexitinroom.exits:
+ ifexit.namenotin("north","east","west","south"):
+ # we only map in the cardinal directions. Mapping up/down would be
+ # an interesting learning project for someone who wanted to try it.
+ continue
+ ifself.has_drawn(exit.destination):
+ # we've been to the destination already, skip ahead.
+ continue
+
+ self.update_pos(room,exit.name.lower())
+ self.draw_room_on_map(exit.destination,max_distance-1)
+
+
+
The first thing the “worm” does is to draw your current location in self.draw. Lets define that…
+
#in mygame/word/map.py, in the Map class
+
+defdraw(self,room):
+ # draw initial ch location on map first!
+ ifroom==self.caller.location:
+ self.start_loc_on_grid()
+ self.worm_has_mapped[room]=[self.curX,self.curY]
+ else:
+ # map all other rooms
+ self.worm_has_mapped[room]=[self.curX,self.curY]
+ # this will use the sector_type Attribute or None if not set.
+ self.grid[self.curX][self.curY]=SYMBOLS[room.db.sector_type]
+
+
+
In self.start_loc_on_grid():
+
defmedian(self,num):
+ lst=sorted(range(0,num))
+ n=len(lst)
+ m=n-1
+ return(lst[n//2]+lst[m//2])/2.0
+
+defstart_loc_on_grid(self):
+ x=self.median(self.max_width)
+ y=self.median(self.max_length)
+ # x and y are floats by default, can't index lists with float types
+ x,y=int(x),int(y)
+
+ self.grid[x][y]=SYMBOLS['you']
+ self.curX,self.curY=x,y# updating worms current location
+
+
+
After the system has drawn the current map it checks to see if the max_distance is 0 (since this
+is the inital start phase it is not). Now we handle the iteration once we have each individual exit
+in the room. The first thing it does is check if the room the Worm is in has been mapped already…
+lets define that…
If has_drawn returns False that means the worm has found a room that hasn’t been mapped yet. It
+will then ‘move’ there. The self.curX/Y sort of lags behind, so we have to make sure to track the
+position of the worm; we do this in self.update_pos() below.
+
defupdate_pos(self,room,exit_name):
+ # this ensures the coordinates stays up to date
+ # to where the worm is currently at.
+ self.curX,self.curY= \
+ self.worm_has_mapped[room][0],self.worm_has_mapped[room][1]
+
+ # now we have to actually move the pointer
+ # variables depending on which 'exit' it found
+ ifexit_name=='east':
+ self.curY+=1
+ elifexit_name=='west':
+ self.curY-=1
+ elifexit_name=='north':
+ self.curX-=1
+ elifexit_name=='south':
+ self.curX+=1
+
+
+
Once the system updates the position of the worm it feeds the new room back into the original
+draw_room_on_map() and starts the process all over again…
+
That is essentially the entire thing. The final method is to bring it all together and make a nice
+presentational string out of it using the self.show_map() method.
In order for the map to get triggered we store it on the Room typeclass. If we put it in
+return_appearance we will get the map back every time we look at the room.
+
+
return_appearance is a default Evennia hook available on all objects; it is called e.g. by the
+look command to get the description of something (the room in this case).
+
+
# in mygame/typeclasses/rooms.py
+
+fromevenniaimportDefaultRoom
+fromworld.mapimportMap
+
+classRoom(DefaultRoom):
+
+ defreturn_appearance(self,looker):
+ # [...]
+ string="%s\n"%Map(looker).show_map()
+ # Add all the normal stuff like room description,
+ # contents, exits etc.
+ string+="\n"+super().return_appearance(looker)
+ returnstring
+
+
+
Obviously this method of generating maps doesn’t take into account of any doors or exits that are
+hidden… etc… but hopefully it serves as a good base to start with. Like previously mentioned, it
+is very important to have a solid foundation on rooms before implementing this. You can try this on
+vanilla evennia by using @tunnel and essentially you can just create a long straight/edgy non-
+looping rooms that will show on your in-game map.
+
The above example will display the map above the room description. You could also use an
+EvTable to place description and map next to each other. Some other
+things you can do is to have a Command that displays with a larger radius, maybe with a
+legend and other features.
+
Below is the whole map.py for your reference. You need to update your Room typeclass (see above)
+to actually call it. Remember that to see different symbols for a location you also need to set the
+sector_type Attribute on the room to one of the keys in the SYMBOLS dictionary. So in this
+example, to make a room be mapped as [.] you would set the room’s sector_type to
+"SECT_INSIDE". Try it out with @sethere/sector_type="SECT_INSIDE". If you wanted all new
+rooms to have a given sector symbol, you could change the default in the SYMBOLS´dictionarybelow,oryoucouldaddtheAttributeintheRoom'sat_object_creation` method.
+
#mygame/world/map.py
+
+# These are keys set with the Attribute sector_type on the room.
+# The keys None and "you" must always exist.
+SYMBOLS={None:' . ',# for rooms without a sector_type attr
+ 'you':'[@]',
+ 'SECT_INSIDE':'[.]'}
+
+classMap(object):
+
+ def__init__(self,caller,max_width=9,max_length=9):
+ self.caller=caller
+ self.max_width=max_width
+ self.max_length=max_length
+ self.worm_has_mapped={}
+ self.curX=None
+ self.curY=None
+
+ ifself.check_grid():
+ # we actually have to store the grid into a variable
+ self.grid=self.create_grid()
+ self.draw_room_on_map(caller.location,
+ ((min(max_width,max_length)-1)/2))
+
+ defupdate_pos(self,room,exit_name):
+ # this ensures the pointer variables always
+ # stays up to date to where the worm is currently at.
+ self.curX,self.curY= \
+ self.worm_has_mapped[room][0],self.worm_has_mapped[room][1]
+
+ # now we have to actually move the pointer
+ # variables depending on which 'exit' it found
+ ifexit_name=='east':
+ self.curY+=1
+ elifexit_name=='west':
+ self.curY-=1
+ elifexit_name=='north':
+ self.curX-=1
+ elifexit_name=='south':
+ self.curX+=1
+
+ defdraw_room_on_map(self,room,max_distance):
+ self.draw(room)
+
+ ifmax_distance==0:
+ return
+
+ forexitinroom.exits:
+ ifexit.namenotin("north","east","west","south"):
+ # we only map in the cardinal directions. Mapping up/down would be
+ # an interesting learning project for someone who wanted to try it.
+ continue
+ ifself.has_drawn(exit.destination):
+ # we've been to the destination already, skip ahead.
+ continue
+
+ self.update_pos(room,exit.name.lower())
+ self.draw_room_on_map(exit.destination,max_distance-1)
+
+ defdraw(self,room):
+ # draw initial caller location on map first!
+ ifroom==self.caller.location:
+ self.start_loc_on_grid()
+ self.worm_has_mapped[room]=[self.curX,self.curY]
+ else:
+ # map all other rooms
+ self.worm_has_mapped[room]=[self.curX,self.curY]
+ # this will use the sector_type Attribute or None if not set.
+ self.grid[self.curX][self.curY]=SYMBOLS[room.db.sector_type]
+
+ defmedian(self,num):
+ lst=sorted(range(0,num))
+ n=len(lst)
+ m=n-1
+ return(lst[n//2]+lst[m//2])/2.0
+
+ defstart_loc_on_grid(self):
+ x=self.median(self.max_width)
+ y=self.median(self.max_length)
+ # x and y are floats by default, can't index lists with float types
+ x,y=int(x),int(y)
+
+ self.grid[x][y]=SYMBOLS['you']
+ self.curX,self.curY=x,y# updating worms current location
+
+
+ defhas_drawn(self,room):
+ returnTrueifroominself.worm_has_mapped.keys()elseFalse
+
+
+ defcreate_grid(self):
+ # This method simply creates an empty grid
+ # with the specified variables from __init__(self):
+ board=[]
+ forrowinrange(self.max_width):
+ board.append([])
+ forcolumninrange(self.max_length):
+ board[row].append(' ')
+ returnboard
+
+ defcheck_grid(self):
+ # this method simply checks the grid to make sure
+ # both max_l and max_w are odd numbers
+ returnTrueifself.max_length%2!=0or \
+ self.max_width%2!=0elseFalse
+
+ defshow_map(self):
+ map_string=""
+ forrowinself.grid:
+ map_string+=" ".join(row)
+ map_string+="\n"
+
+ returnmap_string
+
The Dynamic map could be expanded with further capabilities. For example, it could mark exits or
+allow NE, SE etc directions as well. It could have colors for different terrain types. One could
+also look into up/down directions and figure out how to display that in a good way.
Evennia offers a powerful in-game line editor in evennia.utils.eveditor.EvEditor. This editor,
+mimicking the well-known VI line editor. It offers line-by-line editing, undo/redo, line deletes,
+search/replace, fill, dedent and more.
caller (Object or Account): The user of the editor.
+
loadfunc (callable, optional): This is a function called when the editor is first started. It
+is called with caller as its only argument. The return value from this function is used as the
+starting text in the editor buffer.
+
savefunc (callable, optional): This is called when the user saves their buffer in the editor is
+called with two arguments, caller and buffer, where buffer is the current buffer.
+
quitfunc (callable, optional): This is called when the user quits the editor. If given, all
+cleanup and exit messages to the user must be handled by this function.
+
key (str, optional): This text will be displayed as an identifier and reminder while editing.
+It has no other mechanical function.
+
persistent (default False): if set to True, the editor will survive a reboot.
If you set the persistent keyword to True when creating the editor, it will remain open even
+when reloading the game. In order to be persistent, an editor needs to have its callback functions
+(loadfunc, savefunc and quitfunc) as top-level functions defined in the module. Since these
+functions will be stored, Python will need to find them.
+
fromevenniaimportCommand
+fromevennia.utilsimporteveditor
+
+defload(caller):
+ "get the current value"
+ returncaller.attributes.get("test")
+
+defsave(caller,buffer):
+ "save the buffer"
+ caller.attributes.set("test",buffer)
+
+defquit(caller):
+ "Since we define it, we must handle messages"
+ caller.msg("Editor exited")
+
+classCmdSetTestAttr(Command):
+ """
+ Set the "test" Attribute using
+ the line editor.
+
+ Usage:
+ settestattr
+
+ """
+ key="settestattr"
+ deffunc(self):
+ "Set up the callbacks and launch the editor"
+ key="%s/test"%self.caller
+ # launch the editor
+ eveditor.EvEditor(self.caller,
+ loadfunc=load,savefunc=save,quitfunc=quit,
+ key=key,persistent=True)
+
The editor mimics the VIM editor as best as possible. The below is an excerpt of the return from
+the in-editor help command (:h).
+
<txt> - any non-command is appended to the end of the buffer.
+ : <l> - view buffer or only line <l>
+ :: <l> - view buffer without line numbers or other parsing
+ ::: - print a ':' as the only character on the line...
+ :h - this help.
+
+ :w - save the buffer (don't quit)
+ :wq - save buffer and quit
+ :q - quit (will be asked to save if buffer was changed)
+ :q! - quit without saving, no questions asked
+
+ :u - (undo) step backwards in undo history
+ :uu - (redo) step forward in undo history
+ :UU - reset all changes back to initial state
+
+ :dd <l> - delete line <n>
+ :dw <l> <w> - delete word or regex <w> in entire buffer or on line <l>
+ :DD - clear buffer
+
+ :y <l> - yank (copy) line <l> to the copy buffer
+ :x <l> - cut line <l> and store it in the copy buffer
+ :p <l> - put (paste) previously copied line directly after <l>
+ :i <l> <txt> - insert new text <txt> at line <l>. Old line will move down
+ :r <l> <txt> - replace line <l> with text <txt>
+ :I <l> <txt> - insert text at the beginning of line <l>
+ :A <l> <txt> - append text after the end of line <l>
+
+ :s <l> <w> <txt> - search/replace word or regex <w> in buffer or on line <l>
+
+ :f <l> - flood-fill entire buffer or line <l>
+ :fi <l> - indent entire buffer or line <l>
+ :fd <l> - de-indent entire buffer or line <l>
+
+ :echo - turn echoing of the input on/off (helpful for some clients)
+
+ Legend:
+ <l> - line numbers, or range lstart:lend, e.g. '3:7'.
+ <w> - one word or several enclosed in quotes.
+ <txt> - longer string, usually not needed to be enclosed in quotes.
+
The EvEditor is also used to edit some Python code in Evennia. The @py command supports an
+/edit switch that will open the EvEditor in code mode. This mode isn’t significantly different
+from the standard one, except it handles automatic indentation of blocks and a few options to
+control this behavior.
+
+
:< to remove a level of indentation for the future lines.
+
:+ to add a level of indentation for the future lines.
+
:= to disable automatic indentation altogether.
+
+
Automatic indentation is there to make code editing more simple. Python needs correct indentation,
+not as an aesthetic addition, but as a requirement to determine beginning and ending of blocks. The
+EvEditor will try to guess the next level of indentation. If you type a block “if”, for instance,
+the EvEditor will propose you an additional level of indentation at the next line. This feature
+cannot be perfect, however, and sometimes, you will have to use the above options to handle
+indentation.
+
:= can be used to turn automatic indentation off completely. This can be very useful when trying
+to paste several lines of code that are already correctly indented, for instance.
+
To see the EvEditor in code mode, you can use the @py/edit command. Type in your code (on one or
+several lines). You can then use the :w option (save without quitting) and the code you have
+typed will be executed. The :! will do the same thing. Executing code while not closing the
+editor can be useful if you want to test the code you have typed but add new lines after your test.
The EvMenu utility class is located in
+evennia/utils/evmenu.py.
+It allows for easily adding interactive menus to the game; for example to implement Character
+creation, building commands or similar. Below is an example of offering NPC conversation choices:
+
Theguardlooksatyoususpiciously.
+"No one is supposed to be in here ..."
+hesays,ahandonhisweapon.
+_______________________________________________
+ 1.Trytobribehim[Cha+10gold]
+ 2.Convincehimyouworkhere[Int]
+ 3.Appealtohisvanity[Cha]
+ 4.Trytoknockhimout[Luck+Dex]
+ 5.Trytorunaway[Dex]
+
+
+
+
This is an example of a menu node. Think of a node as a point where the menu stops printing text
+and waits for user to give some input. By jumping to different nodes depending on the input, a menu
+is constructed.
The native way to define an EvMenu is to define Python functions, one per node. It will load all
+those
+functions/nodes either from a module or by being passed a dictionary mapping the node’s names to
+said functions, like {"nodename":<function>,...}. Since you are dealing with raw code, this is
+by
+far the most powerful way - for example you could have dynamic nodes that change content depending
+on game context, time and what you picked before.
For a simpler menu you often don’t need the full flexibility you get from defining each node as a
+Python function. For that, there is the EvMenu templating language. This allows you to define the
+menu
+in a more human-readable string with a simple format. This is then parsed to produce the
+{"nodename":<function>,...} mapping for you, for the EvMenu to use normally. The templating
+language is described in the Menu templating section.
Initializing the menu is done using a call to the evennia.utils.evmenu.EvMenu class. This is the
+most common way to do so - from inside a Command:
+
# in, for example gamedir/commands/command.py
+
+fromevennia.utils.evmenuimportEvMenu
+
+classCmdTestMenu(Command):
+
+ key="testcommand"
+
+ deffunc(self):
+
+ EvMenu(caller,"world.mymenu")
+
+
+
+
When running this command, the menu will start using the menu nodes loaded from
+mygame/world/mymenu.py and use this to build the menu-tree - each function name becomes
+the name of a node in the tree. See next section on how to define menu nodes.
+
Alternatively, you could pass the menu-tree to EvMenu directly:
caller (Object or Account): is a reference to the object using the menu. This object will get a
+new CmdSet assigned to it, for handling the menu.
+
menu_data (str, module or dict): is a module or python path to a module where the global-level
+functions will each be considered to be a menu node. Their names in the module will be the names
+by which they are referred to in the module. Importantly, function names starting with an
+underscore
+_ will be ignored by the loader. Alternatively, this can be a direct mapping
+{"nodename":function,...}.
+
startnode (str): is the name of the menu-node to start the menu at. Changing this means that
+you can jump into a menu tree at different positions depending on circumstance and thus possibly
+re-use menu entries.
+
cmdset_mergetype (str): This is usually one of “Replace” or “Union” (see [CmdSets](Command-
+Sets).
+The first means that the menu is exclusive - the user has no access to any other commands while
+in the menu. The Union mergetype means the menu co-exists with previous commands (and may
+overload
+them, so be careful as to what to name your menu entries in this case).
+
cmdset_priority (int): The priority with which to merge in the menu cmdset. This allows for
+advanced usage.
+
auto_quit, auto_look, auto_help (bool): If either of these are True, the menu
+automatically makes a quit, look or help command available to the user. The main reason why
+you’d want to turn this off is if you want to use the aliases “q”, “l” or “h” for something in
+your
+menu. Nevertheless, at least quit is highly recommend - if False, the menu must itself
+supply
+an “exit node” (a node without any options), or the user will be stuck in the menu until the
+server
+reloads (or eternally if the menu is persistent)!
+
cmd_on_exit (str): This command string will be executed right after the menu has closed down.
+From experience, it’s useful to trigger a “look” command to make sure the user is aware of the
+change of state; but any command can be used. If set to None, no command will be triggered
+after
+exiting the menu.
+
persistent (bool) - if True, the menu will survive a reload (so the user will not be kicked
+out by the reload - make sure they can exit on their own!)
+
startnode_input (str or (str, dict) tuple): Pass an input text or a input text + kwargs to the
+start node as if it was entered on a fictional previous node. This can be very useful in order to
+start a menu differently depending on the Command’s arguments in which it was initialized.
+
session (Session): Useful when calling the menu from an Account in
+MULTISESSION_MODDE higher than 2, to make sure only the right Session sees the menu output.
+
debug (bool): If set, the menudebug command will be made available in the menu. Use it to
+list the current state of the menu and use menudebug<variable> to inspect a specific state
+variable from the list.
+
All other keyword arguments will be available as initial data for the nodes. They will be
+available in all nodes as properties on caller.ndb._menutree (see below). These will also
+survive a @reload if the menu is persistent.
+
+
You don’t need to store the EvMenu instance anywhere - the very act of initializing it will store it
+as caller.ndb._menutree on the caller. This object will be deleted automatically when the menu
+is exited and you can also use it to store your own temporary variables for access throughout the
+menu. Temporary variables you store on a persistent _menutree as it runs will
+not survive a @reload, only those you set as part of the original EvMenu call.
While all of the above forms are okay, it’s recommended to stick to the third and last form since
+it
+gives the most flexibility. The previous forms are mainly there for backwards compatibility with
+existing menus from a time when EvMenu was less able.
caller (Object or Account): The object using the menu - usually a Character but could also be a
+Session or Account depending on where the menu is used.
+
raw_string (str): If this is given, it will be set to the exact text the user entered on the
+previous node (that is, the command entered to get to this node). On the starting-node of the
+menu, this will be an empty string, unless startnode_input was set.
+
kwargs (dict): These extra keyword arguments are extra optional arguments passed to the node
+when the user makes a choice on the previous node. This may include things like status flags
+and details about which exact option was chosen (which can be impossible to determine from
+raw_string alone). Just what is passed in kwargs is up to you when you create the previous
+node.
The text variable is a string or tuple. This text is what will be displayed when the user reaches
+this node. If this is a tuple, then the first element of the tuple will be considered the displayed
+text and the second the help-text to display when the user enters the help command on this node.
+
text=("This is the text to display","This is the help text for this node")
+
+
+
Returning a None text is allowed and simply leads to a node with no text and only options. If the
+help text is not given, the menu will give a generic error message when using help.
The options list describe all the choices available to the user when viewing this node. If
+options is
+returned as None, it means that this node is an Exit node - any text is displayed and then the
+menu immediately exits, running the exit_cmd if given.
+
Otherwise, options should be a list (or tuple) of dictionaries, one for each option. If only one
+option is
+available, a single dictionary can also be returned. This is how it could look:
+
defnode_test(caller,raw_string,**kwargs):
+
+ text="A goblin attacks you!"
+
+ options=(
+ {"key":("Attack","a","att"),
+ "desc":"Strike the enemy with all your might",
+ "goto":"node_attack"},
+ {"key":("Defend","d","def"),
+ "desc":"Hold back and defend yourself",
+ "goto":(_defend,{"str":10,"enemyname":"Goblin"})})
+
+ returntext,options
+
+
+
+
This will produce a menu node looking like this:
+
A goblin attacks you!
+________________________________
+
+Attack: Strike the enemy with all your might
+Defend: Hold back and defend yourself
+
+
The option’s key is what the user should enter in order to choose that option. If given as a
+tuple, the
+first string of that tuple will be what is shown on-screen while the rest are aliases for picking
+that option. In the above example, the user could enter “Attack” (or “attack”, it’s not
+case-sensitive), “a” or “att” in order to attack the goblin. Aliasing is useful for adding custom
+coloring to the choice. The first element of the aliasing tuple should then be the colored version,
+followed by a version without color - since otherwise the user would have to enter the color codes
+to select that choice.
+
Note that the key is optional. If no key is given, it will instead automatically be replaced
+with a running number starting from 1. If removing the key part of each option, the resulting
+menu node would look like this instead:
+
A goblin attacks you!
+________________________________
+
+1: Strike the enemy with all your might
+2: Hold back and defend yourself
+
+
+
+
Whether you want to use a key or rely on numbers is mostly
+a matter of style and the type of menu.
+
EvMenu accepts one important special key given only as "_default". This key is used when a user
+enters something that does not match any other fixed keys. It is particularly useful for getting
+user input:
+
defnode_readuser(caller,raw_string,**kwargs):
+ text="Please enter your name"
+
+ options={"key":"_default",
+ "goto":"node_parse_input"}
+
+ returntext,options
+
+
+
+
A "_default" option does not show up in the menu, so the above will just be a node saying
+"Pleaseenteryourname". The name they entered will appear as raw_string in the next node.
This simply contains the description as to what happens when selecting the menu option. For
+"_default" options or if the key is already long or descriptive, it is not strictly needed. But
+usually it’s better to keep the key short and put more detail in desc.
This is the operational part of the option and fires only when the user chooses said option. Here
+are three ways to write it
+
+def_action_two(caller,raw_string,**kwargs):
+ # do things ...
+ return"calculated_node_to_go_to"
+
+def_action_three(caller,raw_string,**kwargs):
+ # do things ...
+ return"node_four",{"mode":4}
+
+defnode_select(caller,raw_string,**kwargs):
+
+ text=("select one",
+ "help - they all do different things ...")
+
+ options=({"desc":"Option one",
+ "goto":"node_one"},
+ {"desc":"Option two",
+ "goto":_action_two},
+ {"desc":"Option three",
+ "goto":(_action_three,{"key":1,"key2":2})}
+ )
+
+ returntext,options
+
+
+
+
As seen above, goto could just be pointing to a single nodename string - the name of the node to
+go to. When given like this, EvMenu will look for a node named like this and call its associated
+function as
+
nodename(caller,raw_string,**kwargs)
+
+
+
Here, raw_string is always the input the user entered to make that choice and kwargs are the
+same as those kwargs that already entered the current node (they are passed on).
+
Alternatively the goto could point to a “goto-callable”. Such callables are usually defined in the
+same
+module as the menu nodes and given names starting with _ (to avoid being parsed as nodes
+themselves). These callables will be called the same as a node function - callable(caller,raw_string,**kwargs), where raw_string is what the user entered on this node and **kwargs is
+forwarded from the node’s own input.
+
The goto option key could also point to a tuple (callable,kwargs) - this allows for customizing
+the kwargs passed into the goto-callable, for example you could use the same callable but change the
+kwargs passed into it depending on which option was actually chosen.
+
The “goto callable” must either return a string "nodename" or a tuple ("nodename",mykwargs).
+This will lead to the next node being called as either nodename(caller,raw_string,**kwargs) or
+nodename(caller,raw_string,**mykwargs) - so this allows changing (or replacing) the options
+going
+into the next node depending on what option was chosen.
+
There is one important case - if the goto-callable returns None for a nodename, the current
+node will run again, possibly with different kwargs. This makes it very easy to re-use a node over
+and over, for example allowing different options to update some text form being passed and
+manipulated for every iteration.
+
+
The EvMenu also supports the exec option key. This allows for running a callable before the
+goto-callable. This functionality comes from a time before goto could be a callable and is
+deprecated as of Evennia 0.8. Use goto for all functionality where you’d before use exec.
When the menu starts, the EvMenu instance is stored on the caller as caller.ndb._menutree. Through
+this object you can in principle reach the menu’s internal state if you know what you are doing.
+This is also a good place to store temporary, more global variables that may be cumbersome to keep
+passing from node to node via the **kwargs. The _menutree will be deleted automatically when the
+menu closes, meaning you don’t need to worry about cleaning anything up.
+
If you want permanent state storage, it’s instead better to use an Attribute on caller. Remember
+that this will remain after the menu closes though, so you need to handle any needed cleanup
+yourself.
The EvMenu display of nodes, options etc are controlled by a series of formatting methods on the
+EvMenu class. To customize these, simply create a new child class of EvMenu and override as
+needed. Here is an example:
+
fromevennia.utils.evmenuimportEvMenu
+
+classMyEvMenu(EvMenu):
+
+ defnodetext_formatter(self,nodetext):
+ """
+ Format the node text itself.
+
+ Args:
+ nodetext (str): The full node text (the text describing the node).
+
+ Returns:
+ nodetext (str): The formatted node text.
+
+ """
+
+ defhelptext_formatter(self,helptext):
+ """
+ Format the node's help text
+
+ Args:
+ helptext (str): The unformatted help text for the node.
+
+ Returns:
+ helptext (str): The formatted help text.
+
+ """
+
+ defoptions_formatter(self,optionlist):
+ """
+ Formats the option block.
+
+ Args:
+ optionlist (list): List of (key, description) tuples for every
+ option related to this node.
+ caller (Object, Account or None, optional): The caller of the node.
+
+ Returns:
+ options (str): The formatted option display.
+
+ """
+
+ defnode_formatter(self,nodetext,optionstext):
+ """
+ Formats the entirety of the node.
+
+ Args:
+ nodetext (str): The node text as returned by `self.nodetext_formatter`.
+ optionstext (str): The options display as returned by `self.options_formatter`.
+ caller (Object, Account or None, optional): The caller of the node.
+
+ Returns:
+ node (str): The formatted node to display.
+
+ """
+
+
+
+
See evennia/utils/evmenu.py for the details of their default implementations.
The EvMenu is very powerful and flexible. But often your menu is simple enough to
+not require the full power of EvMenu. For this you can use the Evmenu templating language.
+
This is how the templating is used:
+
fromevennia.utils.evmenuimportparse_menu_template,EvMenu
+
+template_string="(will be described below)"
+# this could be empty if you don't need to access any callables
+# in your template
+goto_callables={"mycallable1":function,...}
+
+# generate the menutree
+menutree=parse_menu_template(caller,template_string,goto_callables)
+# a normal EvMenu call
+EvMenu(caller,menutree,...)
+
+
+
+
… So the parse_menu_template is just another way to generate the menutree dict needed by
+EvMenu - after this EvMenu works normally.
+
The good thing with this two-step procedude is that you can mix- and match - if you wanted
+you could insert a normal, fully flexible function-based node-function in the menutree before
+passing
+the whole thing into EvMenu and get the best of both worlds. It also makes it
+easy to substitute base EvMenu with a child class that changes the menu display.
+
… But if you really don’t need any such customization, you can also apply the template in one step
+using
+the template2menu helper:
+
fromevennia.utils.evmenuimporttemplate2menu
+
+template_string="(will be described below)"
+goto_callables={"mycallable1":function,...}
+
+template2menu(caller,template_string,goto_callables,startnode="start",...)
+
+
+
+
In addition to the template-related arguments, template2menu takes all the same **kwargs
+as EvMenu and will parse the template and start the menu for you in one go.
The template is a normal string with a very simple format. Each node begins
+with a marker ##Node<nameofnode>, follwowed by a ##Options separator (the Node and
+Options are
+case-insensitive).
+
template_string="""
+
+## NODE start
+
+<text for the node>
+
+## OPTIONS
+
+# this is a comment. Only line-comments are allowed.
+
+key;alias;alias: description -> goto_str_or_callable
+key;alias;alias: goto_str_or_callable
+>pattern: goto_str_or_callable
+
+"""
+
+
+
+
The text after ##NODE defines the name of the node. This must be unique within the
+menu because this is what you use for goto statements. The name could have spaces.
+
The area between ##NODE and ##OPTIONS contains the text of the node. It can have
+normal formatting and will retain intentation.
+
The ##OPTIONS section, until the next ##NODE or the end of the string,
+holds the options, one per line.
+
Option-indenting is ignored but can be useful for readability.
+
The options-section can also have line-comments, marked by starting the line with #.
+
A node without a following ##OPTIONS section indicates an end node, and reaching
+it will print the text and immediately exit the menu (same as for regular EvMenu).
where you can enter next or n to go to the menu node named node2.
+
To skip the description, just add the goto without the ->:
+
next;n: node2
+
+
+
This will create a menu option without any description:
+
next
+
+
+
A special key is >. This acts as a pattern matcher. Between > and the : one
+can fit an optional pattern. This
+pattern will first be parsed with glob-style
+parsing and then
+with regex, and only if
+the player’s input matches either will the option be chosen. An input-matching
+option cannot have a description.
+
# this matches the empty string (just pressing return)
+ >: node2
+
+ # this matches input starting with 'test' (regex match)
+ > ^test.+?: testnode
+
+ # this matches any number input (regex match)
+ > [0-9]+?: countnode
+
+ # this matches everything not covered by previous options
+ # (glob-matching, space is stripped without quotes)
+ > *: node3
+
+
+
You can have multiple pattern-matchers for a node but remember that options are
+checked in the order they are listed. So make sure to put your pattern-matchers
+in decending order of generality; if you have a ‘catch-all’ pattern,
+it should be put last or those behind it will never be tried.
+
next;n:node2
+ back;b:node1
+ >:node2
+
+
+
The above would give you the option to write next/back but you can also just press return to move on
+to the next node.
Instead of giving the name of a node to go to, you can also give the name
+of a goto_callable, which in turn returns the name of the node to go to. You
+tell the template it’s a callable by simply adding () at the end.
+
next: Go to node 2 -> goto_node2()
+
+
+
You can also add keyword arguments:
+
back: myfunction(from=foo)
+
+
+
+
Note: ONLY keyword-arguments are supported! Trying to pass a positional
+argument will lead to an error.
+
+
The contents of the kwargs-values will be evaluated by literal_eval so
+you don’t need to add quotes to strings unless they have spaces in them. Numbers
+will be converted correctly, but more complex input structures (like lists or dicts) will
+not - if you want more complex input you should use a full function-based EvMenu
+node instead.
+
The goto-callable is defined just like any Evmenu goto-func. You must always
+use the full form (including **kwargs):
Return None to re-run the current node. Any keyword arguments you specify in
+your template will be passed to your goto-callable in **kwargs. Unlike in
+regular EvMenu nodes you can’t return kwargs to pass it between nodes and other dynamic
+tricks.
+
All goto-callables you use in your menu-template must be added to the
+goto_callable mapping that you pass to parse_menu_template or
+template2menu.
+template_string="""
+
+## NODE start
+
+This is the text of the start node.
+Both ## NODE, ## node or ## Node works. The node-name can have
+spaces.
+
+The text area can have multiple lines, line breaks etc.
+
+## OPTIONS
+
+ # here starts the option-defition
+ # comments are only allowed from beginning of line.
+ # Indenting is not necessary, but good for readability
+
+ 1: Option number 1 -> node1
+ 2: Option number 2 -> node2
+ next: This steps next -> go_back()
+ # the -> can be ignored if there is no desc
+ back: go_back(from_node=start)
+ abort: abort
+
+# ----------------------------------- this is ignored
+
+## NODE node1
+
+Text for Node1. Enter a message!
+<return> to go back.
+
+## options
+
+ # Starting the option-line with >
+ # allows to perform different actions depending on
+ # what is inserted.
+
+ # this catches everything starting with foo
+ > foo*: handle_foo_message()
+
+ # regex are also allowed (this catches number inputs)
+ > [0-9]+?: handle_numbers()
+
+ # this catches the empty return
+ >: start
+
+ # this catches everything else
+ > *: handle_message(from_node=node1)
+
+# -----------------------------------------
+
+## NODE node2
+
+Text for Node2. Just go back.
+
+## options
+
+ >: start
+
+# node abort
+
+This exits the menu since there is no `## options` section.
+
+
+"""
+
+# we assume the callables are defined earlier
+goto_callables={"go_back":go_back_func,
+ "handle_foo_message":handle_message,
+ "handle_numbers":my_number_handler,
+ "handle_message":handle_message2}
+
+# boom - a menu
+template2menu(caller,template_string,goto_callables)
+
+
Yes/No prompt - entering text with limited possible responses
+(this is not using EvMenu but the conceptually similar yet technically unrelated get_input
+helper function accessed as evennia.utils.evmenu.get_input).
Below is an example of a simple branching menu node leading to different other nodes depending on
+choice:
+
# in mygame/world/mychargen.py
+
+defdefine_character(caller):
+ text= \
+ """
+ What aspect of your character do you want
+ to change next?
+ """
+ options=({"desc":"Change the name",
+ "goto":"set_name"},
+ {"desc":"Change the description",
+ "goto":"set_description"})
+ returntext,options
+
+EvMenu(caller,"world.mychargen",startnode="define_character")
+
+
+
+
This will result in the following node display:
+
What aspect of your character do you want
+to change next?
+_________________________
+1: Change the name
+2: Change the description
+
+
+
Note that since we didn’t specify the “name” key, EvMenu will let the user enter numbers instead. In
+the following examples we will not include the EvMenu call but just show nodes running inside the
+menu. Also, since EvMenu also takes a dictionary to describe the menu, we could have called it
+like this instead in the example:
+def_is_in_mage_guild(caller,raw_string,**kwargs):
+ ifcaller.tags.get('mage',category="guild_member"):
+ return"mage_guild_welcome"
+ else:
+ return"mage_guild_blocked"
+
+defenter_guild:
+ text='You say to the mage guard:'
+ options({'desc':'I need to get in there.',
+ 'goto':_is_in_mage_guild},
+ {'desc':'Never mind',
+ 'goto':'end_conversation'})
+ returntext,options
+
+
+
This simple callable goto will analyse what happens depending on who the caller is. The
+enter_guild node will give you a choice of what to say to the guard. If you try to enter, you will
+end up in different nodes depending on (in this example) if you have the right Tag set on
+yourself or not. Note that since we don’t include any ‘key’s in the option dictionary, you will just
+get to pick between numbers.
Here is an example of passing arguments into the goto callable and use that to influence
+which node it should go to next:
+
+def_set_attribute(caller,raw_string,**kwargs):
+ "Get which attribute to modify and set it"
+
+ attrname,value=kwargs.get("attr",(None,None))
+ next_node=kwargs.get("next_node")
+
+ caller.attributes.add(attrname,attrvalue)
+
+ returnnext_node
+
+
+defnode_background(caller):
+ text= \
+ """
+ {} experienced a traumatic event
+ in their childhood. What was it?
+ """.format(caller.key}
+
+ options=({"key":"death",
+ "desc":"A violent death in the family",
+ "goto":(_set_attribute,{"attr":("experienced_violence",True),
+ "next_node":"node_violent_background"})},
+ {"key":"betrayal",
+ "desc":"The betrayal of a trusted grown-up",
+ "goto":(_set_attribute,{"attr":("experienced_betrayal",True),
+ "next_node":"node_betrayal_background"})})
+ returntext,options
+
+
+
This will give the following output:
+
Kovash the magnificent experienced a traumatic event
+in their childhood. What was it?
+____________________________________________________
+death: A violent death in the family
+betrayal: The betrayal of a trusted grown-up
+
+
+
+
Note above how we use the _set_attribute helper function to set the attribute depending on the
+User’s choice. In thie case the helper function doesn’t know anything about what node called it - we
+even tell it which nodename it should return, so the choices leads to different paths in the menu.
+We could also imagine the helper function analyzing what other choices
An example of the menu asking the user for input - any input.
+
+def_set_name(caller,raw_string,**kwargs):
+
+ inp=raw_string.strip()
+
+ prev_entry=kwargs.get("prev_entry")
+
+ ifnotinp:
+ # a blank input either means OK or Abort
+ ifprev_entry:
+ caller.key=prev_entry
+ caller.msg("Set name to {}.".format(prev_entry))
+ return"node_background"
+ else:
+ caller.msg("Aborted.")
+ return"node_exit"
+ else:
+ # re-run old node, but pass in the name given
+ returnNone,{"prev_entry":inp}
+
+
+defenter_name(caller,raw_string,**kwargs):
+
+ # check if we already entered a name before
+ prev_entry=kwargs.get("prev_entry")
+
+ ifprev_entry:
+ text="Current name: {}.\nEnter another name or <return> to accept."
+ else:
+ text="Enter your character's name or <return> to abort."
+
+ options={"key":"_default",
+ "goto":(_set_name,{"prev_entry":prev_entry})}
+
+ returntext,options
+
+
+
+
This will display as
+
Enteryourcharacter's name or <return> to abort.
+
+>Gandalf
+
+Currentname:Gandalf
+Enteranothernameor<return>toaccept.
+
+>
+
+SetnametoGandalf.
+
+
+
+
Here we re-use the same node twice for reading the input data from the user. Whatever we enter will
+be caught by the _default option and passed into the helper function. We also pass along whatever
+name we have entered before. This allows us to react correctly on an “empty” input - continue to the
+node named "node_background" if we accept the input or go to an exit node if we presses Return
+without entering anything. By returning None from the helper function we automatically re-run the
+previous node, but updating its ingoing kwargs to tell it to display a different text.
A convenient way to store data is to store it on the caller.ndb._menutree which you can reach from
+every node. The advantage of doing this is that the _menutree NAttribute will be deleted
+automatically when you exit the menu.
+
+def_set_name(caller,raw_string,**kwargs):
+
+ caller.ndb._menutree.charactersheet={}
+ caller.ndb._menutree.charactersheet['name']=raw_string
+ caller.msg("You set your name to {}".format(raw_string)
+ return"background"
+
+defnode_set_name(caller):
+ text='Enter your name:'
+ options={'key':'_default',
+ 'goto':_set_name}
+
+ returntext,options
+
+...
+
+
+defnode_view_sheet(caller):
+ text="Character sheet:\n{}".format(self.ndb._menutree.charactersheet)
+
+ options=({"key":"Accept",
+ "goto":"finish_chargen"},
+ {"key":"Decline",
+ "goto":"start_over"})
+
+ returntext,options
+
+
+
+
Instead of passing the character sheet along from node to node through the kwargs we instead
+set it up temporarily on caller.ndb._menutree.charactersheet. This makes it easy to reach from
+all nodes. At the end we look at it and, if we accept the character the menu will likely save the
+result to permanent storage and exit.
+
+
One point to remember though is that storage on caller.ndb._menutree is not persistent across
+@reloads. If you are using a persistent menu (using EvMenu(...,persistent=True) you should
+use
+caller.db to store in-menu data like this as well. You must then yourself make sure to clean it
+when the user exits the menu.
Sometimes you want to make a chain of menu nodes one after another, but you don’t want the user to
+be able to continue to the next node until you have verified that what they input in the previous
+node is ok. A common example is a login menu:
+
+def_check_username(caller,raw_string,**kwargs):
+ # we assume lookup_username() exists
+ ifnotlookup_username(raw_string):
+ # re-run current node by returning `None`
+ caller.msg("|rUsername not found. Try again.")
+ returnNone
+ else:
+ # username ok - continue to next node
+ return"node_password"
+
+
+defnode_username(caller):
+ text="Please enter your user name."
+ options={"key":"_default",
+ "goto":_check_username}
+ returntext,options
+
+
+def_check_password(caller,raw_string,**kwargs):
+
+ nattempts=kwargs.get("nattempts",0)
+ ifnattempts>3:
+ caller.msg("Too many failed attempts. Logging out")
+ return"node_abort"
+ elifnotvalidate_password(raw_string):
+ caller.msg("Password error. Try again.")
+ returnNone,{"nattempts",nattempts+1}
+ else:
+ # password accepted
+ return"node_login"
+
+defnode_password(caller,raw_string,**kwargs):
+ text="Enter your password."
+ options={"key":"_default",
+ "goto":_check_password}
+ returntext,options
+
+
Here the goto-callables will return to the previous node if there is an error. In the case of
+password attempts, this will tick up the nattempts argument that will get passed on from iteration
+to iteration until too many attempts have been made.
You can also define your nodes directly in a dictionary to feed into the EvMenu creator.
+
defmynode(caller):
+ # a normal menu node function
+ returntext,options
+
+menu_data={"node1":mynode,
+ "node2":lambdacaller:(
+ "This is the node text",
+ ({"key":"lambda node 1",
+ "desc":"go to node 1 (mynode)",
+ "goto":"node1"},
+ {"key":"lambda node 2",
+ "desc":"go to thirdnode",
+ "goto":"node3"})),
+ "node3":lambdacaller,raw_string:(
+ # ... etc ) }
+
+# start menu, assuming 'caller' is available from earlier
+EvMenu(caller,menu_data,startnode="node1")
+
+
+
+
The keys of the dictionary become the node identifiers. You can use any callable on the right form
+to describe each node. If you use Python lambda expressions you can make nodes really on the fly.
+If you do, the lambda expression must accept one or two arguments and always return a tuple with two
+elements (the text of the node and its options), same as any menu node function.
+
Creating menus like this is one way to present a menu that changes with the circumstances - you
+could for example remove or add nodes before launching the menu depending on some criteria. The
+drawback is that a lambda expression is much more
+limited than a full
+function - for example you can’t use other Python keywords like if inside the body of the
+lambda.
+
Unless you are dealing with a relatively simple dynamic menu, defining menus with lambda’s is
+probably more work than it’s worth: You can create dynamic menus by instead making each node
+function more clever. See the NPC shop tutorial for an example of this.
This describes two ways for asking for simple questions from the user. Using Python’s input
+will not work in Evennia. input will block the entire server for everyone until that one
+player has entered their text, which is not what you want.
In the func method of your Commands (only) you can use Python’s built-in yield command to
+request input in a similar way to input. It looks like this:
+
result=yield("Please enter your answer:")
+
+
+
This will send “Please enter your answer” to the Command’s self.caller and then pause at that
+point. All other players at the server will be unaffected. Once caller enteres a reply, the code
+execution will continue and you can do stuff with the result. Here is an example:
+
fromevenniaimportCommand
+classCmdTestInput(Command):
+ key="test"
+ deffunc(self):
+ result=yield("Please enter something:")
+ self.caller.msg(f"You entered {result}.")
+ result2=yield("Now enter something else:")
+ self.caller.msg(f"You now entered {result2}.")
+
+
+
Using yield is simple and intuitive, but it will only access input from self.caller and you
+cannot abort or time out the pause until the player has responded. Under the hood, it is actually
+just a wrapper calling get_input described in the following section.
+
+
Important Note: In Python you cannot mix yield and return<value> in the same method. It has
+to do with yield turning the method into a
+generator. A return without an argument works, you
+can just not do return<value>. This is usually not something you need to do in func() anyway,
+but worth keeping in mind.
The evmenu module offers a helper function named get_input. This is wrapped by the yield
+statement which is often easier and more intuitive to use. But get_input offers more flexibility
+and power if you need it. While in the same module as EvMenu, get_input is technically unrelated
+to it. The get_input allows you to ask and receive simple one-line input from the user without
+launching the full power of a menu to do so. To use, call get_input like this:
+
get_input(caller,prompt,callback)
+
+
+
Here caller is the entity that should receive the prompt for input given as prompt. The
+callback is a callable function(caller,prompt,user_input) that you define to handle the answer
+from the user. When run, the caller will see prompt appear on their screens and any text they
+enter will be sent into the callback for whatever processing you want.
+
Below is a fully explained callback and example call:
+
fromevenniaimportCommand
+fromevennia.utils.evmenuimportget_input
+
+defcallback(caller,prompt,user_input):
+ """
+ This is a callback you define yourself.
+
+ Args:
+ caller (Account or Object): The one being asked
+ for input
+ prompt (str): A copy of the current prompt
+ user_input (str): The input from the account.
+
+ Returns:
+ repeat (bool): If not set or False, exit the
+ input prompt and clean up. If returning anything
+ True, stay in the prompt, which means this callback
+ will be called again with the next user input.
+ """
+ caller.msg(f"When asked '{prompt}', you answered '{user_input}'.")
+
+get_input(caller,"Write something! ",callback)
+
Normally, the get_input function quits after any input, but as seen in the example docs, you could
+return True from the callback to repeat the prompt until you pass whatever check you want.
+
+
Note: You cannot link consecutive questions by putting a new get_input call inside the
+callback. If you want that you should use an EvMenu instead (see the Repeating the same
+node example above). Otherwise you can either peek at the
+implementation of get_input and implement your own mechanism (it’s just using cmdset nesting) or
+you can look at this extension suggested on the mailing
+list.
Below is an example of a Yes/No prompt using the get_input function:
+
defyesno(caller,prompt,result):
+ ifresult.lower()in("y","yes","n","no"):
+ # do stuff to handle the yes/no answer
+ # ...
+ # if we return None/False the prompt state
+ # will quit after this
+ else:
+ # the answer is not on the right yes/no form
+ caller.msg("Please answer Yes or No. \n{prompt}")
+@# returning True will make sure the prompt state is not exited
+ returnTrue
+
+# ask the question
+get_input(caller,"Is Evennia great (Yes/No)?",yesno)
+
The evennia.utils.evmenu.list_node is an advanced decorator for use with EvMenu node functions.
+It is used to quickly create menus for manipulating large numbers of items.
The menu will automatically create an multi-page option listing that one can flip through. One can
+inpect each entry and then select them with prev/next. This is how it is used:
+
fromevennia.utils.evmenuimportlist_node
+
+
+...
+
+_options(caller):
+ return['option1','option2',...'option100']
+
+_select(caller,menuchoice,available_choices):
+ # analyze choice
+ returnnode_matching_the_choice
+
+@list_node(_options,select=_select,pagesize=10)
+defnode_mylist(caller,raw_string,**kwargs):
+ ...
+
+ # the decorator auto-creates the options; any options
+ # returned here would be appended to the auto-options
+ returnnode_text,{}
+
+
+
The options argument to list_node is either a list, a generator or a callable returning a list
+of strings for each option that should be displayed in the node.
+
The select is a callable in the example above but could also be the name of a menu node. If a
+callable, the menuchoice argument holds the selection done and available_choices holds all the
+options available. The callable should return the menu to go to depending on the selection (or
+None to rerun the same node). If the name of a menu node, the selection will be passed as
+selection kwarg to that node.
+
The decorated node itself should return text to display in the node. It must return at least an
+empty dictionary for its options. It returning options, those will supplement the options
+auto-created by the list_node decorator.
The EvMenu is implemented using Commands. When you start a new EvMenu, the user of the
+menu will be assigned a CmdSet with the commands they need to navigate the menu.
+This means that if you were to, from inside the menu, assign a new command set to the caller, you
+may override the Menu Cmdset and kill the menu. If you want to assign cmdsets to the caller as part
+of the menu, you should store the cmdset on caller.ndb._menutree and wait to actually assign it
+until the exit node.
When sending a very long text to a user client, it might scroll beyond of the height of the client
+window. The evennia.utils.evmore.EvMore class gives the user the in-game ability to only view one
+page of text at a time. It is usually used via its access function, evmore.msg.
+
The name comes from the famous unix pager utility more which performs just this function.
Where receiver is an Object or a Account. If the text is longer than the
+client’s screen height (as determined by the NAWS handshake or by settings.CLIENT_DEFAULT_HEIGHT)
+the pager will show up, something like this:
+
+
[…]
+aute irure dolor in reprehenderit in voluptate velit
+esse cillum dolore eu fugiat nulla pariatur. Excepteur
+sint occaecat cupidatat non proident, sunt in culpa qui
+officia deserunt mollit anim id est laborum.
+
+
+
(more [1/6] return|back|top|end|abort)
+
+
where the user will be able to hit the return key to move to the next page, or use the suggested
+commands to jump to previous pages, to the top or bottom of the document as well as abort the
+paging.
+
The pager takes several more keyword arguments for controlling the message output. See the
+evmore-API for more info.
The Evennia game index is a list of games built or
+being built with Evennia. Anyone is allowed to add their game to the index
+
+
also if you have just started development and don’t yet accept external
+players. It’s a chance for us to know you are out there and for you to make us
+intrigued about or excited for your upcoming game!
+
+
All we ask is that you check so your game-name does not collide with one
+already in the list - be nice!
This will start the Evennia Connection wizard. From the menu, select to add
+your game to the Evennia Game Index. Follow the prompts and don’t forget to
+save your new settings in the end. Use quit at any time if you change your
+mind.
+
+
The wizard will create a new file mygame/server/conf/connection_settings.py
+with the settings you chose. This is imported from the end of your main
+settings file and will thus override it. You can edit this new file if you
+want, but remember that if you run the wizard again, your changes may get
+over-written.
If you don’t want to use the wizard (maybe because you already have the client installed from an
+earlier version), you can also configure your index entry in your settings file
+(mygame/server/conf/settings.py). Add the following:
+
GAME_INDEX_ENABLED=True
+
+GAME_INDEX_LISTING={
+ # required
+ 'game_status':'pre-alpha',# pre-alpha, alpha, beta, launched
+ 'listing_contact':"dummy@dummy.com",# not publicly shown.
+ 'short_description':'Short blurb',
+
+ # optional
+ 'long_description':
+ "Longer description that can use Markdown like *bold*, _italic_"
+ "and [linkname](http://link.com). Use \n for line breaks."
+ 'telnet_hostname':'dummy.com',
+ 'telnet_port':'1234',
+ 'web_client_url':'dummy.com/webclient',
+ 'game_website':'dummy.com',
+ # 'game_name': 'MyGame', # set only if different than settings.SERVERNAME
+}
+
+
+
Of these, the game_status, short_description and listing_contact are
+required. The listing_contact is not publicly visible and is only meant as a
+last resort if we need to get in touch with you over any listing issue/bug (so
+far this has never happened).
+
If game_name is not set, the settings.SERVERNAME will be used. Use empty strings
+('') for optional fields you don’t want to specify at this time.
If you don’t specify neither telnet_hostname+port nor
+web_client_url, the Game index will list your game as Not yet public.
+Non-public games are moved to the bottom of the index since there is no way
+for people to try them out. But it’s a good way to show you are out there, even
+if you are not ready for players yet.
A MUD (originally Multi-User Dungeon, with later variants Multi-User Dimension and Multi-User
+Domain) is a multiplayer real-time virtual world described primarily in text. MUDs combine elements
+of role-playing games, hack and slash, player versus player, interactive fiction and online chat.
+Players can read or view descriptions of rooms, objects, other players, non-player characters, and
+actions performed in the virtual world. Players typically interact with each other and the world by
+typing commands that resemble a natural language. - Wikipedia
+
+
If you are reading this, it’s quite likely you are dreaming of creating and running a text-based
+massively-multiplayer game (MUD/MUX/MUSH etc) of your very own. You
+might just be starting to think about it, or you might have lugged around that perfect game in
+your mind for years … you know just how good it would be, if you could only make it come to
+reality. We know how you feel. That is, after all, why Evennia came to be.
+
Evennia is in principle a MUD-building system: a bare-bones Python codebase and server intended to
+be highly extendable for any style of game. “Bare-bones” in this context means that we try to impose
+as few game-specific things on you as possible. So whereas we for convenience offer basic building
+blocks like objects, characters, rooms, default commands for building and administration etc, we
+don’t prescribe any combat rules, mob AI, races, skills, character classes or other things that will
+be different from game to game anyway. It is possible that we will offer some such systems as
+contributions in the future, but these will in that case all be optional.
+
What we do however, is to provide a solid foundation for all the boring database, networking, and
+behind-the-scenes administration stuff that all online games need whether they like it or not.
+Evennia is fully persistent, that means things you drop on the ground somewhere will still be
+there a dozen server reboots later. Through Django we support a large variety of different database
+systems (a database is created for you automatically if you use the defaults).
+
Using the full power of Python throughout the server offers some distinct advantages. All your
+coding, from object definitions and custom commands to AI scripts and economic systems is done in
+normal Python modules rather than some ad-hoc scripting language. The fact that you script the game
+in the same high-level language that you code it in allows for very powerful and custom game
+implementations indeed.
+
The server ships with a default set of player commands that are similar to the MUX command set. We
+do not aim specifically to be a MUX server, but we had to pick some default to go with (see
+this for more about our original motivations). It’s easy to remove or add commands, or
+to have the command syntax mimic other systems, like Diku, LP, MOO and so on. Or why not create a
+new and better command system of your own design.
Evennia’s demo server can be found at demo.evennia.com. If you prefer to
+connect to the demo via your own telnet client you can do so at silvren.com, port 4280. Here is
+a screenshot.
+
Once you installed Evennia yourself it comes with its own tutorial - this shows off some of the
+possibilities and gives you a small single-player quest to play. The tutorial takes only one
+single in-game command to install as explained here.
Game development is done by the server importing your normal Python modules. Specific server
+features are implemented by overloading hooks that the engine calls appropriately.
+
All game entities are simply Python classes that handle database negotiations behind the scenes
+without you needing to worry.
+
Command sets are stored on individual objects (including characters) to offer unique functionality
+and object-specific commands. Sets can be updated and modified on the fly to expand/limit player
+input options during play.
+
Scripts are used to offer asynchronous/timed execution abilities. Scripts can also be persistent.
+There are easy mechanisms to thread particularly long-running processes and built-in ways to start
+“tickers” for games that wants them.
+
In-game communication channels are modular and can be modified to any functionality, including
+mailing systems and full logging of all messages.
+
Server can be fully rebooted/reloaded without users disconnecting.
+
An Account can freely connect/disconnect from game-objects, offering an easy way to implement
+multi-character systems and puppeting.
+
Each Account can optionally control multiple Characters/Objects at the same time using the same
+login information.
+
Spawning of individual objects via a prototypes-like system.
+
Tagging can be used to implement zones and object groupings.
+
All source code is extensively documented.
+
Unit-testing suite, including tests of default commands and plugins.
Assuming you have Evennia working (see the quick start instructions) and have
+gotten as far as to start the server and connect to it with the client of your choice, here’s what
+you need to know depending on your skills and needs.
+
+
I don’t know (or don’t want to do) any programming - I just want to run a game!¶
+
Evennia comes with a default set of commands for the Python newbies and for those who need to get a
+game running now. Stock Evennia is enough for running a simple ‘Talker’-type game - you can build
+and describe rooms and basic objects, have chat channels, do emotes and other things suitable for a
+social or free-form MU*. Combat, mobs and other game elements are not included, so you’ll have a
+very basic game indeed if you are not willing to do at least some coding.
Evennia’s source code is extensively documented and is viewable online.
+We also have a comprehensive online manual with lots of examples.
+But while Python is
+considered a very easy programming language to get into, you do have a learning curve to climb if
+you are new to programming. You should probably sit down
+with a Python beginner’s tutorial (there are plenty of them on
+the web if you look around) so you at least know what you are seeing. See also our
+link page for some reading suggestions. To efficiently code your dream game in
+Evennia you don’t need to be a Python guru, but you do need to be able to read example code
+containing at least these basic Python features:
Obviously, the more things you feel comfortable with, the easier time you’ll have to find your way.
+With just basic knowledge you should be able to define your own Commands, create custom
+Objects as well as make your world come alive with basic Scripts. You can
+definitely build a whole advanced and customized game from extending Evennia’s examples only.
+
+
+
I know my Python stuff and I am willing to use it!¶
+
Even if you started out as a Python beginner, you will likely get to this point after working on
+your game for a while. With more general knowledge in Python the full power of Evennia opens up for
+you. Apart from modifying commands, objects and scripts, you can develop everything from advanced
+mob AI and economic systems, through sophisticated combat and social mini games, to redefining how
+commands, players, rooms or channels themselves work. Since you code your game by importing normal
+Python modules, there are few limits to what you can accomplish.
+
If you also happen to know some web programming (HTML, CSS, Javascript) there is also a web
+presence (a website and a mud web client) to play around with …
From here you can continue browsing the online documentation to
+find more info about Evennia. Or you can jump into the Tutorials and get your hands
+dirty with code right away. You can also read the developer’s dev blog for many tidbits and snippets about Evennia’s development and
+structure.
+
Some more hints:
+
+
Get engaged in the community. Make an introductory post to our mailing list/forum and get to know people. It’s also
+highly recommended you hop onto our Developer chat
+on IRC. This allows you to chat directly with other developers new and old as well as with the devs
+of Evennia itself. This chat is logged (you can find links on http://www.evennia.com) and can also
+be searched from the same place for discussion topics you are interested in.
+
Read the Game Planning wiki page. It gives some ideas for your work flow and the
+state of mind you should aim for - including cutting down the scope of your game for its first
+release.
+
Do the Tutorial for basic MUSH-like game carefully from
+beginning to end and try to understand what does what. Even if you are not interested in a MUSH for
+your own game, you will end up with a small (very small) game that you can build or learn from.
Evennia represents a learning curve for those who used to code on
+Diku type MUDs. While coding in Python is easy if you
+already know C, the main effort is to get rid of old C programming habits. Trying to code Python the
+way you code C will not only look ugly, it will lead to less optimal and harder to maintain code.
+Reading Evennia example code is a good way to get a feel for how different problems are approached
+in Python.
+
Overall, Python offers an extensive library of resources, safe memory management and excellent
+handling of errors. While Python code does not run as fast as raw C code does, the difference is not
+all that important for a text-based game. The main advantage of Python is an extremely fast
+development cycle with and easy ways to create game systems that would take many times more code and
+be much harder to make stable and maintainable in C.
As mentioned, the main difference between Evennia and a Diku-derived codebase is that Evennia is
+written purely in Python. Since Python is an interpreted language there is no compile stage. It is
+modified and extended by the server loading Python modules at run-time. It also runs on all computer
+platforms Python runs on (which is basically everywhere).
+
Vanilla Diku type engines save their data in custom flat file type storage solutions. By
+contrast, Evennia stores all game data in one of several supported SQL databases. Whereas flat files
+have the advantage of being easier to implement, they (normally) lack many expected safety features
+and ways to effectively extract subsets of the stored data. For example, if the server loses power
+while writing to a flatfile it may become corrupt and the data lost. A proper database solution is
+not susceptible to this - at no point is the data in a state where it cannot be recovered. Databases
+are also highly optimized for querying large data sets efficiently.
Diku expresses the character object referenced normally by:
+
structcharch* then all character-related fields can be accessed by ch->. In Evennia, one must
+pay attention to what object you are using, and when you are accessing another through back-
+handling, that you are accessing the right object. In Diku C, accessing character object is normally
+done by:
+
/* creating pointer of both character and room struct */
+
+void(structcharch*,structroomroom*){
+intdam;
+if(ROOM_FLAGGED(room,ROOM_LAVA)){
+dam=100
+ch->damage_taken=dam
+};
+};
+
+
+
As an example for creating Commands in Evennia via the fromevenniaimportCommand the character
+object that calls the command is denoted by a class property as self.caller. In this example
+self.caller is essentially the ‘object’ that has called the Command, but most of the time it is an
+Account object. For a more familiar Diku feel, create a variable that becomes the account object as:
+
#mygame/commands/command.py
+
+fromevenniaimportCommand
+
+classCmdMyCmd(Command):
+ """
+ This is a Command Evennia Object
+ """
+
+ [...]
+
+ deffunc(self):
+ ch=self.caller
+ # then you can access the account object directly by using the familiar ch.
+ ch.msg("...")
+ account_name=ch.name
+ race=ch.db.race
+
+
+
+
As mentioned above, care must be taken what specific object you are working with. If focused on a
+room object and you need to access the account object:
+
#mygame/typeclasses/room.py
+
+fromevenniaimportDefaultRoom
+
+classMyRoom(DefaultRoom):
+ [...]
+
+ defis_account_object(self,object):
+ # a test to see if object is an account
+ [...]
+
+ defmyMethod(self):
+ #self.caller would not make any sense, since self refers to the
+ # object of 'DefaultRoom', you must find the character obj first:
+ forchinself.contents:
+ ifself.is_account_object(ch):
+ # now you can access the account object with ch:
+ account_name=ch.name
+ race=ch.db.race
+
+
+
+
+
Emulating Evennia to Look and Feel Like A Diku/ROM¶
+
To emulate a Diku Mud on Evennia some work has to be done before hand. If there is anything that all
+coders and builders remember from Diku/Rom days is the presence of VNUMs. Essentially all data was
+saved in flat files and indexed by VNUMs for easy access. Evennia has the ability to emulate VNUMS
+to the extent of categorising rooms/mobs/objs/trigger/zones[…] into vnum ranges.
+
Evennia has objects that are called Scripts. As defined, they are the ‘out of game’ instances that
+exist within the mud, but never directly interacted with. Scripts can be used for timers, mob AI,
+and even a stand alone databases.
+
Because of their wonderful structure all mob, room, zone, triggers, etc… data can be saved in
+independently created global scripts.
+
Here is a sample mob file from a Diku Derived flat file.
+
#0
+mob0~
+mob0~
+mob0
+~
+ Mob0
+~
+10 0 0 0 0 0 0 0 0 E
+1 20 9 0d0+10 1d2+0
+10 100
+8 8 0
+E
+#1
+Puff dragon fractal~
+Puff~
+Puff the Fractal Dragon is here, contemplating a higher reality.
+~
+ Is that some type of differential curve involving some strange, and unknown
+calculus that she seems to be made out of?
+~
+516106 0 0 0 2128 0 0 0 1000 E
+34 9 -10 6d6+340 5d5+5
+340 115600
+8 8 2
+BareHandAttack: 12
+E
+T 95
+
+
+
Each line represents something that the MUD reads in and does something with it. This isn’t easy to
+read, but let’s see if we can emulate this as a dictionary to be stored on a database script created
+in Evennia.
+
First, let’s create a global script that does absolutely nothing and isn’t attached to anything. You
+can either create this directly in-game with the @py command or create it in another file to do some
+checks and balances if for whatever reason the script needs to be created again. Progmatically it
+can be done like so:
Just by creating a simple script object and assigning it a ‘vnums’ attribute as a type dictionary.
+Next we have to create the mob layout…
+
# vnum : mob_data
+
+mob_vnum_1={
+ 'key':'puff',
+ 'sdesc':'puff the fractal dragon',
+ 'ldesc':'Puff the Fractal Dragon is here, ' \
+ 'contemplating a higher reality.',
+ 'ddesc':' Is that some type of differential curve ' \
+ 'involving some strange, and unknown calculus ' \
+ 'that she seems to be made out of?',
+ [...]
+ }
+
+# Then saving it to the data, assuming you have the script obj stored in a variable.
+mob_db.db.vnums[1]=mob_vnum_1
+
+
+
This is a very ‘caveman’ example, but it gets the idea across. You can use the keys in the
+mob_db.vnums to act as the mob vnum while the rest contains the data…
+
Much simpler to read and edit. If you plan on taking this route, you must keep in mind that by
+default evennia ‘looks’ at different properties when using the look command for instance. If you
+create an instance of this mob and make its self.key=1, by default evennia will say
+
Hereis:1
+
You must restructure all default commands so that the mud looks at different properties defined on
+your mob.
This page is adopted from an article originally posted for the MUSH community here on
+musoapbox.net.
+
MUSHes are text multiplayer games traditionally used for
+heavily roleplay-focused game styles. They are often (but not always) utilizing game masters and
+human oversight over code automation. MUSHes are traditionally built on the TinyMUSH-family of game
+servers, like PennMUSH, TinyMUSH, TinyMUX and RhostMUSH. Also their siblings
+MUCK and MOO are
+often mentioned together with MUSH since they all inherit from the same
+TinyMUD base. A major feature is the
+ability to modify and program the game world from inside the game by using a custom scripting
+language. We will refer to this online scripting as softcode here.
+
Evennia works quite differently from a MUSH both in its overall design and under the hood. The same
+things are achievable, just in a different way. Here are some fundamental differences to keep in
+mind if you are coming from the MUSH world.
In MUSH, users tend to code and expand all aspects of the game from inside it using softcode. A MUSH
+can thus be said to be managed solely by Players with different levels of access. Evennia on the
+other hand, differentiates between the role of the Player and the Developer.
+
+
An Evennia Developer works in Python from outside the game, in what MUSH would consider
+“hardcode”. Developers implement larger-scale code changes and can fundamentally change how the game
+works. They then load their changes into the running Evennia server. Such changes will usually not
+drop any connected players.
+
An Evennia Player operates from inside the game. Some staff-level players are likely to double
+as developers. Depending on access level, players can modify and expand the game’s world by digging
+new rooms, creating new objects, alias commands, customize their experience and so on. Trusted staff
+may get access to Python via the @py command, but this would be a security risk for normal Players
+to use. So the Player usually operates by making use of the tools prepared for them by the
+Developer - tools that can be as rigid or flexible as the developer desires.
For a Player, collaborating on a game need not be too different between MUSH and Evennia. The
+building and description of the game world can still happen mostly in-game using build commands,
+using text tags and inline functions to prettify and customize the
+experience. Evennia offers external ways to build a world but those are optional. There is also
+nothing in principle stopping a Developer from offering a softcode-like language to Players if
+that is deemed necessary.
+
For Developers of the game, the difference is larger: Code is mainly written outside the game in
+Python modules rather than in-game on the command line. Python is a very popular and well-supported
+language with tons of documentation and help to be found. The Python standard library is also a
+great help for not having to reinvent the wheel. But that said, while Python is considered one of
+the easier languages to learn and use it is undoubtedly very different from MUSH softcode.
+
While softcode allows collaboration in-game, Evennia’s external coding instead opens up the
+possibility for collaboration using professional version control tools and bug tracking using
+websites like github (or bitbucket for a free private repo). Source code can be written in proper
+text editors and IDEs with refactoring, syntax highlighting and all other conveniences. In short,
+collaborative development of an Evennia game is done in the same way most professional collaborative
+development is done in the world, meaning all the best tools can be used.
Inheritance works differently in Python than in softcode. Evennia has no concept of a “master
+object” that other objects inherit from. There is in fact no reason at all to introduce “virtual
+objects” in the game world - code and data are kept separate from one another.
+
In Python (which is an object oriented
+language) one instead creates classes - these are like blueprints from which you spawn any number
+of object instances. Evennia also adds the extra feature that every instance is persistent in the
+database (this means no SQL is ever needed). To take one example, a unique character in Evennia is
+an instances of the class Character.
+
One parallel to MUSH’s @parent command may be Evennia’s @typeclass command, which changes which
+class an already existing object is an instance of. This way you can literally turn a Character
+into a Flowerpot on the spot.
+
if you are new to object oriented design it’s important to note that all object instances of a class
+does not have to be identical. If they did, all Characters would be named the same. Evennia allows
+to customize individual objects in many different ways. One way is through Attributes, which are
+database-bound properties that can be linked to any object. For example, you could have an Orc
+class that defines all the stuff an Orc should be able to do (probably in turn inheriting from some
+Monster class shared by all monsters). Setting different Attributes on different instances
+(different strength, equipment, looks etc) would make each Orc unique despite all sharing the same
+class.
+
The @spawn command allows one to conveniently choose between different “sets” of Attributes to
+put on each new Orc (like the “warrior” set or “shaman” set) . Such sets can even inherit one
+another which is again somewhat remniscent at least of the effect of @parent and the object-
+based inheritance of MUSH.
+
There are other differences for sure, but that should give some feel for things. Enough with the
+theory. Let’s get down to more practical matters next. To install, see the
+Getting Started instructions.
By default Evennia’s desc command updates your description and that’s it. There is a more feature-
+rich optional “multi-descer” in evennia/contrib/multidesc.py though. This alternative allows for
+managing and combining a multitude of keyed descriptions.
+
To activate the multi-descer, cd to your game folder and into the commands sub-folder. There
+you’ll find the file default_cmdsets.py. In Python lingo all *.py files are called modules.
+Open the module in a text editor. We won’t go into Evennia in-game Commands and Command sets
+further here, but suffice to say Evennia allows you to change which commands (or versions of
+commands) are available to the player from moment to moment depending on circumstance.
+
Add two new lines to the module as seen below:
+
# the file mygame/commands/default_cmdsets.py
+# [...]
+
+fromevennia.contribimportmultidescer# <- added now
+
+classCharacterCmdSet(default_cmds.CharacterCmdSet):
+ """
+ The CharacterCmdSet contains general in-game commands like look,
+ get etc available on in-game Character objects. It is merged with
+ the AccountCmdSet when an Account puppets a Character.
+ """
+ key="DefaultCharacter"
+
+ defat_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ super().at_cmdset_creation()
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(multidescer.CmdMultiDesc())# <- added now
+# [...]
+
+
+
Note that Python cares about indentation, so make sure to indent with the same number of spaces as
+shown above!
+
So what happens above? We import the moduleevennia/contrib/multidescer.py at the top. Once
+imported we can access stuff inside that module using full stop (.). The multidescer is defined as
+a class CmdMultiDesc (we could find this out by opening said module in a text editor). At the
+bottom we create a new instance of this class and add it to the CharacterCmdSet class. For the
+sake of this tutorial we only need to know that CharacterCmdSet contains all commands that should
+be be available to the Character by default.
+
This whole thing will be triggered when the command set is first created, which happens on server
+start. So we need to reload Evennia with @reload - no one will be disconnected by doing this. If
+all went well you should now be able to use desc (or +desc) and find that you have more
+possibilities:
+
> help +desc # get help on the command
+> +desc eyes = His eyes are blue.
+> +desc basic = A big guy.
+> +desc/set basic + + eyes # we add an extra space between
+> look me
+A big guy. His eyes are blue.
+
+
+
If there are errors, a traceback will show in the server log - several lines of text showing
+where the error occurred. Find where the error is by locating the line number related to the
+default_cmdsets.py file (it’s the only one you’ve changed so far). Most likely you mis-spelled
+something or missed the indentation. Fix it and either @reload again or run evenniastart as
+needed.
This use of + was prescribed by the Developer that coded this +desc command. What if the
+Player doesn’t like this syntax though? Do players need to pester the dev to change it? Not
+necessarily. While Evennia does not allow the player to build their own multi-descer on the command
+line, it does allow for re-mapping the command syntax to one they prefer. This is done using the
+nick command.
+
Here’s a nick that changes how to input the command above:
The string on the left will be matched against your input and if matching, it will be replaced with
+the string on the right. The $-type tags will store space-separated arguments and put them into
+the replacement. The nick allows shell-like wildcards, so you
+can use *, ?, [...], [!...] etc to match parts of the input.
+
The same description as before can now be set as
+
> setdesc basic cape footwear attitude
+
+
+
With the nick functionality players can mitigate a lot of syntax dislikes even without the
+developer changing the underlying Python code.
If you are a Developer and are interested in making a more MUSH-like Evennia game, a good start is
+to look into the Evennia Tutorial for a first MUSH-like game.
+That steps through building a simple little game from scratch and helps to acquaint you with the
+various corners of Evennia. There is also the Tutorial for running roleplaying sessions that can be of interest.
+
An important aspect of making things more familiar for Players is adding new and tweaking existing
+commands. How this is done is covered by the Tutorial on adding new commands. You may also find it useful to shop through the evennia/contrib/ folder. The Tutorial
+world is a small single-player quest you can try (it’s not very MUSH-
+like but it does show many Evennia concepts in action). Beyond that there are many more
+tutorials to try out. If you feel you want a more visual overview you can also look at
+Evennia in pictures.
This tutorial will explain how to set up a realtime or play-by-post tabletop style game using a
+fresh Evennia server.
+
The scenario is thus: You and a bunch of friends want to play a tabletop role playing game online.
+One of you will be the game master and you are all okay with playing using written text. You want
+both the ability to role play in real-time (when people happen to be online at the same time) as
+well as the ability for people to post when they can and catch up on what happened since they were
+last online.
+
This is the functionality we will be needing and using:
+
+
The ability to make one of you the GM (game master), with special abilities.
+
A Character sheet that players can create, view and fill in. It can also be locked so only the
+GM can modify it.
+
A dice roller mechanism, for whatever type of dice the RPG rules require.
+
Rooms, to give a sense of location and to compartmentalize play going on- This means both
+Character movements from location to location and GM explicitly moving them around.
+
Channels, for easily sending text to all subscribing accounts, regardless of location.
+
Account-to-Account messaging capability, including sending to multiple recipients
+simultaneously, regardless of location.
+
+
We will find most of these things are already part of vanilla Evennia, but that we can expand on the
+defaults for our particular use-case. Below we will flesh out these components from start to finish.
We will assume you start from scratch. You need Evennia installed, as per the Getting
+Started instructions. Initialize a new game directory with evenniainit<gamedirname>. In this tutorial we assume your game dir is simply named mygame. You can use the
+default database and keep all other settings to default for now. Familiarize yourself with the
+mygame folder before continuing. You might want to browse the [First Steps Coding](First-Steps-
+Coding) tutorial, just to see roughly where things are modified.
Simplest way: Being an admin, just give one account Admins permission using the standard @perm
+command.
+
Better but more work: Make a custom command to set/unset the above, while tweaking the Character
+to show your renewed GM status to the other accounts.
Evennia has the following permission hierarchy out of
+the box: Players, Helpers, Builders, Admins and finally Developers. We could change these but
+then we’d need to update our Default commands to use the changes. We want to keep this simple, so
+instead we map our different roles on top of this permission ladder.
+
+
Players is the permission set on normal players. This is the default for anyone creating a new
+account on the server.
+
Helpers are like Players except they also have the ability to create/edit new help entries.
+This could be granted to players who are willing to help with writing lore or custom logs for
+everyone.
+
Builders is not used in our case since the GM should be the only world-builder.
+
Admins is the permission level the GM should have. Admins can do everything builders can
+(create/describe rooms etc) but also kick accounts, rename them and things like that.
+
Developers-level permission are the server administrators, the ones with the ability to
+restart/shutdown the server as well as changing the permission levels.
+
+
+
The superuser is not part of the hierarchy and actually
+completely bypasses it. We’ll assume server admin(s) will “just” be Developers.
There is no need to remove the basic Players permission when adding the higher permission: the
+highest will be used. Permission level names are not case sensitive. You can also use both plural
+and singular, so “Admins” gives the same powers as “Admin”.
Use of @perm works out of the box, but it’s really the bare minimum. Would it not be nice if other
+accounts could tell at a glance who the GM is? Also, we shouldn’t really need to remember that the
+permission level is called “Admins”. It would be easier if we could just do @gm<account> and
+@notgm<account> and at the same time change something make the new GM status apparent.
+
So let’s make this possible. This is what we’ll do:
+
+
We’ll customize the default Character class. If an object of this class has a particular flag,
+its name will have the string(GM) added to the end.
+
We’ll add a new command, for the server admin to assign the GM-flag properly.
Let’s first start by customizing the Character. We recommend you browse the beginning of the
+Account page to make sure you know how Evennia differentiates between the OOC “Account
+objects” (not to be confused with the Accounts permission, which is just a string specifying your
+access) and the IC “Character objects”.
+
Open mygame/typeclasses/characters.py and modify the default Character class:
+
# in mygame/typeclasses/characters.py
+
+# [...]
+
+classCharacter(DefaultCharacter):
+ # [...]
+ defget_display_name(self,looker,**kwargs):
+ """
+ This method customizes how character names are displayed. We assume
+ only permissions of types "Developers" and "Admins" require
+ special attention.
+ """
+ name=self.key
+ selfaccount=self.account# will be None if we are not puppeted
+ lookaccount=looker.account# - " -
+
+ ifselfaccountandselfaccount.db.is_gm:
+ # A GM. Show name as name(GM)
+ name="%s(GM)"%name
+
+ iflookaccountand \
+ (lookaccount.permissions.get("Developers")orlookaccount.db.is_gm):
+ # Developers/GMs see name(#dbref) or name(GM)(#dbref)
+ return"%s(#%s)"%(name,self.id)
+ else:
+ returnname
+
+
+
+
Above, we change how the Character’s name is displayed: If the account controlling this Character is
+a GM, we attach the string (GM) to the Character’s name so everyone can tell who’s the boss. If we
+ourselves are Developers or GM’s we will see database ids attached to Characters names, which can
+help if doing database searches against Characters of exactly the same name. We base the “gm-
+ingness” on having an flag (an Attribute) named is_gm. We’ll make sure new GM’s
+actually get this flag below.
+
+
Extra exercise: This will only show the (GM) text on Characters puppeted by a GM account,
+that is, it will show only to those in the same location. If we wanted it to also pop up in, say,
+who listings and channels, we’d need to make a similar change to the Account typeclass in
+mygame/typeclasses/accounts.py. We leave this as an exercise to the reader.
We will describe in some detail how to create and add an Evennia command here with the
+hope that we don’t need to be as detailed when adding commands in the future. We will build on
+Evennia’s default “mux-like” commands here.
+
Open mygame/commands/command.py and add a new Command class at the bottom:
+
# in mygame/commands/command.py
+
+fromevenniaimportdefault_cmds
+
+# [...]
+
+importevennia
+
+classCmdMakeGM(default_cmds.MuxCommand):
+ """
+ Change an account's GM status
+
+ Usage:
+ @gm <account>
+ @ungm <account>
+
+ """
+ # note using the key without @ means both @gm !gm etc will work
+ key="gm"
+ aliases="ungm"
+ locks="cmd:perm(Developers)"
+ help_category="RP"
+
+ deffunc(self):
+ "Implement the command"
+ caller=self.caller
+
+ ifnotself.args:
+ caller.msg("Usage: @gm account or @ungm account")
+ return
+
+ accountlist=evennia.search_account(self.args)# returns a list
+ ifnotaccountlist:
+ caller.msg("Could not find account '%s'"%self.args)
+ return
+ eliflen(accountlist)>1:
+ caller.msg("Multiple matches for '%s': %s"%(self.args,accountlist))
+ return
+ else:
+ account=accountlist[0]
+
+ ifself.cmdstring=="gm":
+ # turn someone into a GM
+ ifaccount.permissions.get("Admins"):
+ caller.msg("Account %s is already a GM."%account)
+ else:
+ account.permissions.add("Admins")
+ caller.msg("Account %s is now a GM."%account)
+ account.msg("You are now a GM (changed by %s)."%caller)
+ account.character.db.is_gm=True
+ else:
+ # @ungm was entered - revoke GM status from someone
+ ifnotaccount.permissions.get("Admins"):
+ caller.msg("Account %s is not a GM."%account)
+ else:
+ account.permissions.remove("Admins")
+ caller.msg("Account %s is no longer a GM."%account)
+ account.msg("You are no longer a GM (changed by %s)."%caller)
+ delaccount.character.db.is_gm
+
+
+
+
All the command does is to locate the account target and assign it the Admins permission if we
+used @gm or revoke it if using the @ungm alias. We also set/unset the is_gm Attribute that is
+expected by our new Character.get_display_name method from earlier.
+
+
We could have made this into two separate commands or opted for a syntax like @gm/revoke<accountname>. Instead we examine how this command was called (stored in self.cmdstring) in order
+to act accordingly. Either way works, practicality and coding style decides which to go with.
+
+
To actually make this command available (only to Developers, due to the lock on it), we add it to
+the default Account command set. Open the file mygame/commands/default_cmdsets.py and find the
+AccountCmdSet class:
Finally, issue the @reload command to update the server to your changes. Developer-level players
+(or the superuser) should now have the @gm/@ungm command available.
There are many ways to build a Character sheet in text, from manually pasting strings together to
+more automated ways. Exactly what is the best/easiest way depends on the sheet one tries to create.
+We will here show two examples using the EvTable and EvForm utilities.Later we will create
+Commands to edit and display the output from those utilities.
+
+
Note that due to the limitations of the wiki, no color is used in any of the examples. See the
+text tag documentation for how to add color to the tables and forms.
EvTable is a text-table generator. It helps with displaying text in
+ordered rows and columns. This is an example of using it in code:
+
# this can be tried out in a Python shell like iPython
+
+fromevennia.utilsimportevtable
+
+# we hardcode these for now, we'll get them as input later
+STR,CON,DEX,INT,WIS,CHA=12,13,8,10,9,13
+
+table=evtable.EvTable("Attr","Value",
+ table=[
+ ["STR","CON","DEX","INT","WIS","CHA"],
+ [STR,CON,DEX,INT,WIS,CHA]
+ ],align='r',border="incols")
+
+
+
Above, we create a two-column table by supplying the two columns directly. We also tell the table to
+be right-aligned and to use the “incols” border type (borders drawns only in between columns). The
+EvTable class takes a lot of arguments for customizing its look, you can see some of the possible
+keyword arguments here. Once you have the table you
+could also retroactively add new columns and rows to it with table.add_row() and
+table.add_column(): if necessary the table will expand with empty rows/columns to always remain
+rectangular.
This is a minimalistic but effective Character sheet. By combining the table_string with other
+strings one could build up a reasonably full graphical representation of a Character. For more
+advanced layouts we’ll look into EvForm next.
EvForm allows the creation of a two-dimensional “graphic” made by
+text characters. On this surface, one marks and tags rectangular regions (“cells”) to be filled with
+content. This content can be either normal strings or EvTable instances (see the previous section,
+one such instance would be the table variable in that example).
+
In the case of a Character sheet, these cells would be comparable to a line or box where you could
+enter the name of your character or their strength score. EvMenu also easily allows to update the
+content of those fields in code (it use EvTables so you rebuild the table first before re-sending it
+to EvForm).
+
The drawback of EvForm is that its shape is static; if you try to put more text in a region than it
+was sized for, the text will be cropped. Similarly, if you try to put an EvTable instance in a field
+too small for it, the EvTable will do its best to try to resize to fit, but will eventually resort
+to cropping its data or even give an error if too small to fit any data.
+
An EvForm is defined in a Python module. Create a new file mygame/world/charsheetform.py and
+modify it thus:
The #coding statement (which must be put on the very first line to work) tells Python to use the
+utf-8 encoding for the file. Using the FORMCHAR and TABLECHAR we define what single-character we
+want to use to “mark” the regions of the character sheet holding cells and tables respectively.
+Within each block (which must be separated from one another by at least one non-marking character)
+we embed identifiers 1-4 to identify each block. The identifier could be any single character except
+for the FORMCHAR and TABLECHAR
+
+
You can still use FORMCHAR and TABLECHAR elsewhere in your sheet, but not in a way that it
+would identify a cell/table. The smallest identifiable cell/table area is 3 characters wide
+including the identifier (for example x2x).
+
+
Now we will map content to this form.
+
# again, this can be tested in a Python shell
+
+# hard-code this info here, later we'll ask the
+# account for this info. We will re-use the 'table'
+# variable from the EvTable example.
+
+NAME="John, the wise old admin with a chip on his shoulder"
+ADVANTAGES="Language-wiz, Intimidation, Firebreathing"
+DISADVANTAGES="Bad body odor, Poor eyesight, Troubled history"
+
+fromevennia.utilsimportevform
+
+# load the form from the module
+form=evform.EvForm("world/charsheetform.py")
+
+# map the data to the form
+form.map(cells={"1":NAME,"3":ADVANTAGES,"4":DISADVANTAGES},
+ tables={"2":table})
+
+
+
We create some RP-sounding input and re-use the table variable from the previous EvTable
+example.
+
+
Note, that if you didn’t want to create the form in a separate module you could also load it
+directly into the EvForm call like this: EvForm(form={"FORMCHAR":"x","TABLECHAR":"c","FORM":formstring}) where FORM specifies the form as a string in the same way as listed in the module
+above. Note however that the very first line of the FORM string is ignored, so start with a \n.
As seen, the texts and tables have been slotted into the text areas and line breaks have been added
+where needed. We chose to just enter the Advantages/Disadvantages as plain strings here, meaning
+long names ended up split between rows. If we wanted more control over the display we could have
+inserted \n line breaks after each line or used a borderless EvTable to display those as well.
We will assume we go with the EvForm example above. We now need to attach this to a Character so
+it can be modified. For this we will modify our Character class a little more:
+
# mygame/typeclasses/character.py
+
+fromevennia.utilsimportevform,evtable
+
+[...]
+
+classCharacter(DefaultCharacter):
+ [...]
+ defat_object_creation(self):
+ "called only once, when object is first created"
+ # we will use this to stop account from changing sheet
+ self.db.sheet_locked=False
+ # we store these so we can build these on demand
+ self.db.chardata={"str":0,
+ "con":0,
+ "dex":0,
+ "int":0,
+ "wis":0,
+ "cha":0,
+ "advantages":"",
+ "disadvantages":""}
+ self.db.charsheet=evform.EvForm("world/charsheetform.py")
+ self.update_charsheet()
+
+ defupdate_charsheet(self):
+ """
+ Call this to update the sheet after any of the ingoing data
+ has changed.
+ """
+ data=self.db.chardata
+ table=evtable.EvTable("Attr","Value",
+ table=[
+ ["STR","CON","DEX","INT","WIS","CHA"],
+ [data["str"],data["con"],data["dex"],
+ data["int"],data["wis"],data["cha"]]],
+ align='r',border="incols")
+ self.db.charsheet.map(tables={"2":table},
+ cells={"1":self.key,
+ "3":data["advantages"],
+ "4":data["disadvantages"]})
+
+
+
+
Use @reload to make this change available to all newly created Characters. Already existing
+Characters will not have the charsheet defined, since at_object_creation is only called once.
+The easiest to force an existing Character to re-fire its at_object_creation is to use the
+@typeclass command in-game:
We will add a command to edit the sections of our Character sheet. Open
+mygame/commands/command.py.
+
# at the end of mygame/commands/command.py
+
+ALLOWED_ATTRS=("str","con","dex","int","wis","cha")
+ALLOWED_FIELDNAMES=ALLOWED_ATTRS+ \
+ ("name","advantages","disadvantages")
+
+def_validate_fieldname(caller,fieldname):
+ "Helper function to validate field names."
+ iffieldnamenotinALLOWED_FIELDNAMES:
+ err="Allowed field names: %s"%(", ".join(ALLOWED_FIELDNAMES))
+ caller.msg(err)
+ returnFalse
+ iffieldnameinALLOWED_ATTRSandnotvalue.isdigit():
+ caller.msg("%s must receive a number."%fieldname)
+ returnFalse
+ returnTrue
+
+classCmdSheet(MuxCommand):
+ """
+ Edit a field on the character sheet
+
+ Usage:
+ @sheet field value
+
+ Examples:
+ @sheet name Ulrik the Warrior
+ @sheet dex 12
+ @sheet advantages Super strength, Night vision
+
+ If given without arguments, will view the current character sheet.
+
+ Allowed field names are:
+ name,
+ str, con, dex, int, wis, cha,
+ advantages, disadvantages
+
+ """
+
+ key="sheet"
+ aliases="editsheet"
+ locks="cmd: perm(Players)"
+ help_category="RP"
+
+ deffunc(self):
+ caller=self.caller
+ ifnotself.argsorlen(self.args)<2:
+ # not enough arguments. Display the sheet
+ ifsheet:
+ caller.msg(caller.db.charsheet)
+ else:
+ caller.msg("You have no character sheet.")
+ return
+
+ # if caller.db.sheet_locked:
+ caller.msg("Your character sheet is locked.")
+ return
+
+ # split input by whitespace, once
+ fieldname,value=self.args.split(None,1)
+ fieldname=fieldname.lower()# ignore case
+
+ ifnot_validate_fieldnames(caller,fieldname):
+ return
+ iffieldname=="name":
+ self.key=value
+ else:
+ caller.chardata[fieldname]=value
+ caller.update_charsheet()
+ caller.msg("%s was set to %s."%(fieldname,value))
+
+
+
+
Most of this command is error-checking to make sure the right type of data was input. Note how the
+sheet_locked Attribute is checked and will return if not set.
+
This command you import into mygame/commands/default_cmdsets.py and add to the CharacterCmdSet,
+in the same way the @gm command was added to the AccountCmdSet earlier.
Game masters use basically the same input as Players do to edit a character sheet, except they can
+do it on other players than themselves. They are also not stopped by any sheet_locked flags.
+
# continuing in mygame/commands/command.py
+
+classCmdGMsheet(MuxCommand):
+ """
+ GM-modification of char sheets
+
+ Usage:
+ @gmsheet character [= fieldname value]
+
+ Switches:
+ lock - lock the character sheet so the account
+ can no longer edit it (GM's still can)
+ unlock - unlock character sheet for Account
+ editing.
+
+ Examples:
+ @gmsheet Tom
+ @gmsheet Anna = str 12
+ @gmsheet/lock Tom
+
+ """
+ key="gmsheet"
+ locks="cmd: perm(Admins)"
+ help_category="RP"
+
+ deffunc(self):
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("Usage: @gmsheet character [= fieldname value]")
+
+ ifself.rhs:
+ # rhs (right-hand-side) is set only if a '='
+ # was given.
+ iflen(self.rhs)<2:
+ caller.msg("You must specify both a fieldname and value.")
+ return
+ fieldname,value=self.rhs.split(None,1)
+ fieldname=fieldname.lower()
+ ifnot_validate_fieldname(caller,fieldname):
+ return
+ charname=self.lhs
+ else:
+ # no '=', so we must be aiming to look at a charsheet
+ fieldname,value=None,None
+ charname=self.args.strip()
+
+ character=caller.search(charname,global_search=True)
+ ifnotcharacter:
+ return
+
+ if"lock"inself.switches:
+ ifcharacter.db.sheet_locked:
+ caller.msg("The character sheet is already locked.")
+ else:
+ character.db.sheet_locked=True
+ caller.msg("%s can no longer edit their character sheet."%character.key)
+ elif"unlock"inself.switches:
+ ifnotcharacter.db.sheet_locked:
+ caller.msg("The character sheet is already unlocked.")
+ else:
+ character.db.sheet_locked=False
+ caller.msg("%s can now edit their character sheet."%character.key)
+
+ iffieldname:
+ iffieldname=="name":
+ character.key=value
+ else:
+ character.db.chardata[fieldname]=value
+ character.update_charsheet()
+ caller.msg("You set %s's %s to %s."%(character.key,fieldname,value)
+ else:
+ # just display
+ caller.msg(character.db.charsheet)
+
+
+
The @gmsheet command takes an additional argument to specify which Character’s character sheet to
+edit. It also takes /lock and /unlock switches to block the Player from tweaking their sheet.
+
Before this can be used, it should be added to the default CharacterCmdSet in the same way as the
+normal @sheet. Due to the lock set on it, this command will only be available to Admins (i.e.
+GMs) or higher permission levels.
Evennia’s contrib folder already comes with a full dice roller. To add it to the game, simply
+import contrib.dice.CmdDice into mygame/commands/default_cmdsets.py and add CmdDice to the
+CharacterCmdset as done with other commands in this tutorial. After a @reload you will be able
+to roll dice using normal RPG-style format:
+
roll2d6+3
+7
+
+
+
Use helpdice to see what syntax is supported or look at evennia/contrib/dice.py to see how it’s
+implemented.
Evennia comes with rooms out of the box, so no extra work needed. A GM will automatically have all
+needed building commands available. A fuller go-through is found in the Building
+tutorial. Here are some useful highlights:
+
+
@digroomname;alias=exit_there;alias,exit_back;alias - this is the basic command for digging
+a new room. You can specify any exit-names and just enter the name of that exit to go there.
+
@tunneldirection=roomname - this is a specialized command that only accepts directions in the
+cardinal directions (n,ne,e,se,s,sw,w,nw) as well as in/out and up/down. It also automatically
+builds “matching” exits back in the opposite direction.
+
@create/dropobjectname - this creates and drops a new simple object in the current location.
+
@descobj - change the look-description of the object.
+
@telobject=location - teleport an object to a named location.
+
@searchobjectname - locate an object in the database.
+
+
+
TODO: Describe how to add a logging room, that logs says and poses to a log file that people can
+access after the fact.
Evennia comes with Channels in-built and they are described fully in the
+documentation. For brevity, here are the relevant commands for normal use:
+
+
@ccreatenew_channel;alias;alias=shortdescription - Creates a new channel.
+
addcomchannel - join an existing channel. Use addcomalias=channel to add a new alias you
+can use to talk to the channel, as many as desired.
+
delcomaliasorchannel - remove an alias from a channel or, if the real channel name is given,
+unsubscribe completely.
+
@channels lists all available channels, including your subscriptions and any aliases you have
+set up for them.
+
+
You can read channel history: if you for example are chatting on the public channel you can do
+public/history to see the 20 last posts to that channel or public/history32 to view twenty
+posts backwards, starting with the 32nd from the end.
The @py command supplied with the default command set of Evennia allows you to execute Python
+commands directly from inside the game. An alias to @py is simply “!”. Access to the @py
+command should be severely restricted. This is no joke - being able to execute arbitrary Python
+code on the server is not something you should entrust to just anybody.
evennia - Evennia’s flat API - through this you can access all of Evennia.
+
+
For accessing other objects in the same room you need to use self.search(name). For objects in
+other locations, use one of the evennia.search_* methods. See [below](./Execute-Python-Code.md#finding-
+objects).
This is an example where we import and test one of Evennia’s utilities found in
+src/utils/utils.py, but also accessible through ev.utils:
+
@py from ev import utils; utils.time_format(33333)
+<<< Done.
+
+
+
Note that we didn’t get any return value, all we where told is that the code finished executing
+without error. This is often the case in more complex pieces of code which has no single obvious
+return value. To see the output from the time_format() function we need to tell the system to
+echo it to us explicitly with self.msg().
+
@py from ev import utils; self.msg(str(utils.time_format(33333)))
+09:15
+<<< Done.
+
+
+
+
Warning: When using the msg function wrap our argument in str() to convert it into a string
+above. This is not strictly necessary for most types of data (Evennia will usually convert to a
+string behind the scenes for you). But for lists and tuples you will be confused by the output
+if you don’t wrap them in str(): only the first item of the iterable will be returned. This is
+because doing msg(text) is actually just a convenience shortcut; the full argument that msg
+accepts is something called an outputfunc on the form (cmdname,(args),{kwargs}) (see the
+message path for more info). Sending a list/tuple confuses Evennia to think you are
+sending such a structure. Converting it to a string however makes it clear it should just be
+displayed as-is.
+
+
If you were to use Python’s standard print, you will see the result in your current stdout (your
+terminal by default, otherwise your log file).
A common use for @py is to explore objects in the database, for debugging and performing specific
+operations that are not covered by a particular command.
+
Locating an object is best done using self.search():
self.search() is by far the most used case, but you can also search other database tables for
+other Evennia entities like scripts or configuration entities. To do this you can use the generic
+search entries found in ev.search_*.
+
@py evennia.search_script("sys_game_time")
+<<< [<src.utils.gametime.GameTime object at 0x852be2c>]
+
+
+
(Note that since this becomes a simple statement, we don’t have to wrap it in self.msg() to get
+the output). You can also use the database model managers directly (accessible through the objects
+properties of database models or as evennia.managers.*). This is a bit more flexible since it
+gives you access to the full range of database search methods defined in each manager.
+
@py evennia.managers.scripts.script_search("sys_game_time")
+<<< [<src.utils.gametime.GameTime object at 0x852be2c>]
+
+
+
The managers are useful for all sorts of database studies.
@py has the advantage of operating inside a running server (sharing the same process), where you
+can test things in real time. Much of this can be done from the outside too though.
+
In a terminal, cd to the top of your game directory (this bit is important since we need access to
+your config file) and run
+
evennia shell
+
+
+
Your default Python interpreter will start up, configured to be able to work with and import all
+modules of your Evennia installation. From here you can explore the database and test-run individual
+modules as desired.
+
It’s recommended that you get a more fully featured Python interpreter like
+iPython. If you use a virtual environment, you can just get it
+with pipinstallipython. IPython allows you to better work over several lines, and also has a lot
+of other editing features, such as tab-completion and __doc__-string reading.
This section gives a brief step-by-step introduction on how to set up Evennia for the first time so
+you can modify and overload the defaults easily. You should only need to do these steps once. It
+also walks through you making your first few tweaks.
+
Before continuing, make sure you have Evennia installed and running by following the Getting
+Started instructions. You should have initialized a new game folder with the
+evennia--initfoldername command. We will in the following assume this folder is called
+“mygame”.
+
It might be a good idea to eye through the brief Coding Introduction too
+(especially the recommendations in the section about the evennia “flat” API and about using evenniashell will help you here and in the future).
+
To follow this tutorial you also need to know the basics of operating your computer’s
+terminal/command line. You also need to have a text editor to edit and create source text files.
+There are plenty of online tutorials on how to use the terminal and plenty of good free text
+editors. We will assume these things are already familiar to you henceforth.
Below are some first things to try with your new custom modules. You can test these to get a feel
+for the system. See also Tutorials for more step-by-step help and special cases.
We will add some simple rpg attributes to our default Character. In the next section we will follow
+up with a new command to view those attributes.
+
+
Edit mygame/typeclasses/characters.py and modify the Character class. The
+at_object_creation method also exists on the DefaultCharacter parent and will overload it. The
+get_abilities method is unique to our version of Character.
+
classCharacter(DefaultCharacter):
+ # [...]
+ defat_object_creation(self):
+ """
+ Called only at initial creation. This is a rather silly
+ example since ability scores should vary from Character to
+ Character and is usually set during some character
+ generation step instead.
+ """
+ #set persistent attributes
+ self.db.strength=5
+ self.db.agility=4
+ self.db.magic=2
+
+ defget_abilities(self):
+ """
+ Simple access method to return ability
+ scores as a tuple (str,agi,mag)
+ """
+ returnself.db.strength,self.db.agility,self.db.magic
+
+
+
+
Reload the server (you will still be connected to the game after doing
+this). Note that if you examine yourself you will not see any new Attributes appear yet. Read
+the next section to understand why.
It’s important to note that the new Attributes we added above will only be stored on
+newly created characters. The reason for this is simple: The at_object_creation method, where we
+added those Attributes, is per definition only called when the object is first created, then never
+again. This is usually a good thing since those Attributes may change over time - calling that hook
+would reset them back to start values. But it also means that your existing character doesn’t have
+them yet. You can see this by calling the get_abilities hook on yourself at this point:
+
# (you have to be superuser to use @py)
+@pyself.get_abilities()
+<<<(None,None,None)
+
+
+
This is easily remedied.
+
@updateself
+
+
+
This will (only) re-run at_object_creation on yourself. You should henceforth be able to get the
+abilities successfully:
+
@pyself.get_abilities()
+<<<(5,4,2)
+
+
+
This is something to keep in mind if you start building your world before your code is stable -
+startup-hooks will not (and should not) automatically run on existing objects - you have to update
+your existing objects manually. Luckily this is a one-time thing and pretty simple to do. If the
+typeclass you want to update is in typeclasses.myclass.MyClass, you can do the following (e.g.
+from evenniashell):
+
fromtypeclasses.myclassimportMyClass
+# loop over all MyClass instances in the database
+# and call .swap_typeclass on them
+forobjinMyClass.objects.all():
+ obj.swap_typeclass(MyClass,run_start_hooks="at_object_creation")
+
+
+
Using swap_typeclass to the same typeclass we already have will re-run the creation hooks (this is
+what the @update command does under the hood). From in-game you can do the same with @py:
One may experience errors for a number of reasons. Common beginner errors are spelling mistakes,
+wrong indentations or code omissions leading to a SyntaxError. Let’s say you leave out a colon
+from the end of a class function like so: defat_object_creation(self). The client will reload
+without issue. However, if you look at the terminal/console (i.e. not in-game), you will see
+Evennia complaining (this is called a traceback):
Evennia will still be restarting and following the tutorial, doing @pyself.get_abilities() will
+return the right response (None,None,None). But when attempting to @typeclass/forceself you
+will get this response:
The full error will show in the terminal/console but this is confusing since you did add
+get_abilities before. Note however what the error says - you (self) should be a Character but
+the error talks about DefaultObject. What has happened is that due to your unhandled SyntaxError
+earlier, Evennia could not load the character.py module at all (it’s not valid Python). Rather
+than crashing, Evennia handles this by temporarily falling back to a safe default - DefaultObject
+
+
in order to keep your MUD running. Fix the original SyntaxError and reload the server. Evennia
+will then be able to use your modified Character class again and things should work.
+
+
+
Note: Learning how to interpret an error traceback is a critical skill for anyone learning Python.
+Full tracebacks will appear in the terminal/Console you started Evennia from. The traceback text can
+sometimes be quite long, but you are usually just looking for the last few lines: The description of
+the error and the filename + line number for where the error occurred. In the example above, we see
+it’s a SyntaxError happening at line33 of mygame\typeclasses\characters.py. In this case it
+even points out where on the line it encountered the error (the missing colon). Learn to read
+tracebacks and you’ll be able to resolve the vast majority of common errors easily.
The @py command used above is only available to privileged users. We want any player to be able to
+see their stats. Let’s add a new command to list the abilities we added in the previous
+section.
+
+
Open mygame/commands/command.py. You could in principle put your command anywhere but this
+module has all the imports already set up along with some useful documentation. Make a new class at
+the bottom of this file:
+
classCmdAbilities(BaseCommand):
+ """
+ List abilities
+
+ Usage:
+ abilities
+
+ Displays a list of your current ability values.
+ """
+ key="abilities"
+ aliases=["abi"]
+ lock="cmd:all()"
+ help_category="General"
+
+ deffunc(self):
+ """implements the actual functionality"""
+
+ str,agi,mag=self.caller.get_abilities()
+ string="STR: %s, AGI: %s, MAG: %s"%(str,agi,mag)
+ self.caller.msg(string)
+
+
+
+
Next you edit mygame/commands/default_cmdsets.py and add a new import to it near the top:
+
fromcommands.commandimportCmdAbilities
+
+
+
+
In the CharacterCmdSet class, add the following near the bottom (it says where):
+
self.add(CmdAbilities())
+
+
+
+
Reload the server (noone will be disconnected by doing this).
+
+
You (and anyone else) should now be able to use abilities (or its alias abi) as part of your
+normal commands in-game:
Let’s test to make a new type of object. This example is an “wise stone” object that returns some
+random comment when you look at it, like this:
+
> look stone
+
+A very wise stone
+
+This is a very wise old stone.
+It grumbles and says: 'The world is like a rock of chocolate.'
+
+
+
+
Create a new module in mygame/typeclasses/. Name it wiseobject.py for this example.
+
In the module import the base Object (typeclasses.objects.Object). This is empty by default,
+meaning it is just a proxy for the default evennia.DefaultObject.
+
Make a new class in your module inheriting from Object. Overload hooks on it to add new
+functionality. Here is an example of how the file could look:
+
fromrandomimportchoice
+fromtypeclasses.objectsimportObject
+
+classWiseObject(Object):
+ """
+ An object speaking when someone looks at it. We
+ assume it looks like a stone in this example.
+ """
+ defat_object_creation(self):
+ """Called when object is first created"""
+ self.db.wise_texts= \
+ ["Stones have feelings too.",
+ "To live like a stone is to not have lived at all.",
+ "The world is like a rock of chocolate."]
+
+ defreturn_appearance(self,looker):
+ """
+ Called by the look command. We want to return
+ a wisdom when we get looked at.
+ """
+ # first get the base string from the
+ # parent's return_appearance.
+ string=super().return_appearance(looker)
+ wisewords="\n\nIt grumbles and says: '%s'"
+ wisewords=wisewords%choice(self.db.wise_texts)
+ returnstring+wisewords
+
+
+
+
Check your code for bugs. Tracebacks will appear on your command line or log. If you have a grave
+Syntax Error in your code, the source file itself will fail to load which can cause issues with the
+entire cmdset. If so, fix your bug and reload the server from the command line
+(noone will be disconnected by doing this).
+
Use @create/dropstone:wiseobject.WiseObject to create a talkative stone. If the @create
+command spits out a warning or cannot find the typeclass (it will tell you which paths it searched),
+re-check your code for bugs and that you gave the correct path. The @create command starts looking
+for Typeclasses in mygame/typeclasses/.
+
Use lookstone to test. You will see the default description (“You see nothing special”)
+followed by a random message of stony wisdom. Use @descstone=Thisisawiseoldstone. to make
+it look nicer. See the Builder Docs for more information.
+
+
Note that at_object_creation is only called once, when the stone is first created. If you make
+changes to this method later, already existing stones will not see those changes. As with the
+Character example above you can use @typeclass/force to tell the stone to re-run its
+initialization.
+
The at_object_creation is a special case though. Changing most other aspects of the typeclass does
+not require manual updating like this - you just need to @reload to have all changes applied
+automatically to all existing objects.
There are more Tutorials, including one for building a whole little MUSH-like
+game - that is instructive also if you have no interest in
+MUSHes per se. A good idea is to also get onto the IRC
+chat and the mailing
+list to get in touch with the community and other
+developers.
So you have Evennia up and running. You have a great game idea in mind. Now it’s time to start
+cracking! But where to start? Here are some ideas for a workflow. Note that the suggestions on this
+page are just that - suggestions. Also, they are primarily aimed at a lone hobby designer or a small
+team developing a game in their free time.
+
Below are some minimal steps for getting the first version of a new game world going with players.
+It’s worth to at least make the attempt to do these steps in order even if you are itching to jump
+ahead in the development cycle. On the other hand, you should also make sure to keep your work fun
+for you, or motivation will falter. Making a full game is a lot of work as it is, you’ll need all
+your motivation to make it a reality.
+
Remember that 99.99999% of all great game ideas never lead to a game. Especially not to an online
+game that people can actually play and enjoy. So our first all overshadowing goal is to beat those
+odds and get something out the door! Even if it’s a scaled-down version of your dream game,
+lacking many “must-have” features! It’s better to get it out there and expand on it later than to
+code in isolation forever until you burn out, lose interest or your hard drive crashes.
+
Like is common with online games, getting a game out the door does not mean you are going to be
+“finished” with the game - most MUDs add features gradually over the course of years - it’s often
+part of the fun!
This is what you do before having coded a single line or built a single room. Many prospective game
+developers are very good at parts of this process, namely in defining what their world is “about”:
+The theme, the world concept, cool monsters and so on. It is by all means very important to define
+what is the unique appeal of your game. But it’s unfortunately not enough to make your game a
+reality. To do that you must also have an idea of how to actually map those great ideas onto
+Evennia.
+
A good start is to begin by planning out the basic primitives of the game and what they need to be
+able to do. Below are a far-from-complete list of examples (and for your first version you should
+definitely try for a much shorter list):
These are the behind-the-scenes features that exist in your game, often without being represented by
+a specific in-game object.
+
+
Should your game rules be enforced by coded systems or are you planning for human game masters to
+run and arbitrate rules?
+
What are the actual mechanical game rules? How do you decide if an action succeeds or fails? What
+“rolls” does the game need to be able to do? Do you base your game off an existing system or make up
+your own?
+
Does the flow of time matter in your game - does night and day change? What about seasons? Maybe
+your magic system is affected by the phase of the moon?
+
Do you want changing, global weather? This might need to operate in tandem over a large number of
+rooms.
+
Do you want a game-wide economy or just a simple barter system? Or no formal economy at all?
+
Should characters be able to send mail to each other in-game?
+
Should players be able to post on Bulletin boards?
+
What is the staff hierarchy in your game? What powers do you want your staff to have?
+
What should a Builder be able to build and what commands do they need in order to do that?
Is a simple description enough or should the description be able to change (such as with time, by
+light conditions, weather or season)?
+
Should the room have different statuses? Can it have smells, sounds? Can it be affected by
+dramatic weather, fire or magical effects? If so, how would this affect things in the room? Or are
+these things something admins/game masters should handle manually?
+
Can objects be hidden in the room? Can a person hide in the room? How does the room display this?
Consider the most basic (non-player-controlled) object in your game.
+
+
How numerous are your objects? Do you want large loot-lists or are objects just role playing props
+created on demand?
+
Does the game use money? If so, is each coin a separate object or do you just store a bank account
+value?
+
What about multiple identical objects? Do they form stacks and how are those stacks handled in
+that case?
+
Does an object have weight or volume (so you cannot carry an infinite amount of them)?
+
Can objects be broken? If so, does it have a health value? Is burning it causing the same damage
+as smashing it? Can it be repaired?
+
Is a weapon a specific type of object or are you supposed to be able to fight with a chair too?
+Can you fight with a flower or piece of paper as well?
+
NPCs/mobs are also objects. Should they just stand around or should they have some sort of AI?
+
Are NPCs/mobs differet entities? How is an Orc different from a Kobold, in code - are they the
+same object with different names or completely different types of objects, with custom code?
+
Should there be NPCs giving quests? If so, how would you track quest status and what happens when
+multiple players try to do the same quest? Do you use instances or some other mechanism?
These are the objects controlled directly by Players.
+
+
Can players have more than one Character active at a time or are they allowed to multi-play?
+
How does a Player create their Character? A Character-creation screen? Answering questions?
+Filling in a form?
+
Do you want to use classes (like “Thief”, “Warrior” etc) or some other system, like Skill-based?
+
How do you implement different “classes” or “races”? Are they separate types of objects or do you
+simply load different stats on a basic object depending on what the Player wants?
+
If a Character can hide in a room, what skill will decide if they are detected?
+
What skill allows a Character to wield a weapon and hit? Do they need a special skill to wield a
+chair rather than a sword?
+
Does a Character need a Strength attribute to tell how much they can carry or which objects they
+can smash?
+
What does the skill tree look like? Can a Character gain experience to improve? By killing
+enemies? Solving quests? By roleplaying?
+
etc.
+
+
A MUD’s a lot more involved than you would think and these things hang together in a complex web. It
+can easily become overwhelming and it’s tempting to want all functionality right out of the door.
+Try to identify the basic things that “make” your game and focus only on them for your first
+release. Make a list. Keep future expansions in mind but limit yourself.
This is the actual work of creating the “game” part of your game. Many “game-designer” types tend to
+gloss over this bit and jump directly to World Building. Vice versa, many “game-coder” types
+tend to jump directly to this part without doing the Planning first. Neither way is good and
+will lead to you having to redo all your hard work at least once, probably more.
+
Evennia’s Developer Central tries to help you with this bit of development. We
+also have a slew of Tutorials with worked examples. Evennia tries hard to make this
+part easier for you, but there is no way around the fact that if you want anything but a very basic
+Talker-type game you will have to bite the bullet and code your game (or find a coder willing to
+do it for you).
+
Even if you won’t code anything yourself, as a designer you need to at least understand the basic
+paradigms of Evennia, such as Objects, Commands and Scripts and
+how they hang together. We recommend you go through the [Tutorial World](Tutorial-World-
+Introduction) in detail (as well as glancing at its code) to get at least a feel for what is
+involved behind the scenes. You could also look through the tutorial for building a game from
+scratch.
+
During Coding you look back at the things you wanted during the Planning phase and try to
+implement them. Don’t be shy to update your plans if you find things easier/harder than you thought.
+The earlier you revise problems, the easier they will be to fix.
+
A good idea is to host your code online (publicly or privately) using version control. Not only will
+this make it easy for multiple coders to collaborate (and have a bug-tracker etc), it also means
+your work is backed up at all times. The Version Control tutorial has
+instructions for setting up a sane developer environment with proper version control.
This is an integral part of your Coding. It might seem obvious to experienced coders, but it cannot
+be emphasized enough that you should test things on a small scale before putting your untested
+code into a large game-world. The earlier you test, the easier and cheaper it will be to fix bugs
+and even rework things that didn’t work out the way you thought they would. You might even have to
+go back to the Planning phase if your ideas can’t handle their meet with reality.
+
This means building singular in-game examples. Make one room and one object of each important type
+and test so they work correctly in isolation. Then add more if they are supposed to interact with
+each other in some way. Build a small series of rooms to test how mobs move around … and so on. In
+short, a test-bed for your growing code. It should be done gradually until you have a fully
+functioning (if not guaranteed bug-free) miniature tech demo that shows all the features you want
+in the first release of your game. There does not need to be any game play or even a theme to your
+tests, this is only for you and your co-coders to see. The more testing you do on this small scale,
+the less headaches you will have in the next phase.
Up until this point we’ve only had a few tech-demo objects in the database. This step is the act of
+populating the database with a larger, thematic world. Too many would-be developers jump to this
+stage too soon (skipping the Coding or even Planning stages). What if the rooms you build
+now doesn’t include all the nice weather messages the code grows to support? Or the way you store
+data changes under the hood? Your building work would at best require some rework and at worst you
+would have to redo the whole thing. And whereas Evennia’s typeclass system does allow you to edit
+the properties of existing objects, some hooks are only called at object creation … Suffice to
+say you are in for a lot of unnecessary work if you build stuff en masse without having the
+underlying code systems in some reasonable shape first.
+
So before starting to build, the “game” bit (Coding + Testing) should be more or less
+complete, at least to the level of your initial release.
+
Before starting to build, you should also plan ahead again. Make sure it is clear to yourself and
+your eventual builders just which parts of the world you want for your initial release. Establish
+for everyone which style, quality and level of detail you expect. Your goal should not be to
+complete your entire world in one go. You want just enough to make the game’s “feel” come across.
+You want a minimal but functioning world where the intended game play can be tested and roughly
+balanced. You can always add new areas later.
+
During building you get free and extensive testing of whatever custom build commands and systems you
+have made at this point. Since Building often involves different people than those Coding, you also
+get a chance to hear if some things are hard to understand or non-intuitive. Make sure to respond
+to this feedback.
As mentioned, don’t hold onto your world more than necessary. Get it out there with a huge Alpha
+flag and let people try it! Call upon your alpha-players to try everything - they will find ways
+to break your game in ways that you never could have imagined. In Alpha you might be best off to
+focus on inviting friends and maybe other MUD developers, people who you can pester to give proper
+feedback and bug reports (there will be bugs, there is no way around it). Follow the quick
+instructions for Online Setup to make your game visible online. If you hadn’t
+already, make sure to put up your game on the Evennia game index so
+people know it’s in the works (actually, even pre-alpha games are allowed in the index so don’t be
+shy)!
Once things stabilize in Alpha you can move to Beta and let more people in. Many MUDs are in
+perpetual beta, meaning they are never considered
+“finished”, but just repeat the cycle of Planning, Coding, Testing and Building over and over as new
+features get implemented or Players come with suggestions. As the game designer it is now up to you
+to gradually perfect your vision.
A lot of games use a separate time system we refer to as game time. This runs in parallel to what
+we usually think of as real time. The game time might run at a different speed, use different
+names for its time units or might even use a completely custom calendar. You don’t need to rely on a
+game time system at all. But if you do, Evennia offers basic tools to handle these various
+situations. This tutorial will walk you through these features.
Many games let their in-game time run faster or slower than real time, but still use our normal
+real-world calendar. This is common both for games set in present day as well as for games in
+historical or futuristic settings. Using a standard calendar has some advantages:
+
+
Handling repetitive actions is much easier, since converting from the real time experience to the
+in-game perceived one is easy.
+
The intricacies of the real world calendar, with leap years and months of different length etc are
+automatically handled by the system.
+
+
Evennia’s game time features assume a standard calendar (see the relevant section below for a custom
+calendar).
All is done through the settings. Here are the settings you should use if you want a game time with
+a standard calendar:
+
# in a file settings.py in mygame/server/conf
+# The time factor dictates if the game world runs faster (timefactor>1)
+# or slower (timefactor<1) than the real world.
+TIME_FACTOR=2.0
+
+# The starting point of your game time (the epoch), in seconds.
+# In Python a value of 0 means Jan 1 1970 (use negatives for earlier
+# start date). This will affect the returns from the utils.gametime
+# module.
+TIME_GAME_EPOCH=None
+
+
+
By default, the game time runs twice as fast as the real time. You can set the time factor to be 1
+(the game time would run exactly at the same speed than the real time) or lower (the game time will
+be slower than the real time). Most games choose to have the game time spinning faster (you will
+find some games that have a time factor of 60, meaning the game time runs sixty times as fast as the
+real time, a minute in real time would be an hour in game time).
+
The epoch is a slightly more complex setting. It should contain a number of seconds that would
+indicate the time your game started. As indicated, an epoch of 0 would mean January 1st, 1970. If
+you want to set your time in the future, you just need to find the starting point in seconds. There
+are several ways to do this in Python, this method will show you how to do it in local time:
+
# We're looking for the number of seconds representing
+# January 1st, 2020
+fromdatetimeimportdatetime
+importtime
+start=datetime(2020,1,1)
+time.mktime(start.timetuple())
+
+
+
This should return a huge number - the number of seconds since Jan 1 1970. Copy that directly into
+your settings (editing server/conf/settings.py):
+
# in a file settings.py in mygame/server/conf
+TIME_GAME_EPOCH=1577865600
+
+
+
Reload the game with @reload, and then use the @time command. You should see something like
+this:
The line that is most relevant here is the game time epoch. You see it shown at 2020-01-01. From
+this point forward, the game time keeps increasing. If you keep typing @time, you’ll see the game
+time updated correctly… and going (by default) twice as fast as the real time.
The gametime utility also has a way to schedule game-related events, taking into account your game
+time, and assuming a standard calendar (see below for the same feature with a custom calendar). For
+instance, it can be used to have a specific message every (in-game) day at 6:00 AM showing how the
+sun rises.
+
The function schedule() should be used here. It will create a script with some
+additional features to make sure the script is always executed when the game time matches the given
+parameters.
+
The schedule function takes the following arguments:
+
+
The callback, a function to be called when time is up.
+
The keyword repeat (False by default) to indicate whether this function should be called
+repeatedly.
+
Additional keyword arguments sec, min, hour, day, month and year to describe the time
+to schedule. If the parameter isn’t given, it assumes the current time value of this specific unit.
+
+
Here is a short example for making the sun rise every day:
+
# in a file ingame_time.py in mygame/world/
+
+fromevennia.utilsimportgametime
+fromtypeclasses.roomsimportRoom
+
+defat_sunrise():
+ """When the sun rises, display a message in every room."""
+ # Browse all rooms
+ forroominRoom.objects.all():
+ room.msg_contents("The sun rises from the eastern horizon.")
+
+defstart_sunrise_event():
+ """Schedule an sunrise event to happen every day at 6 AM."""
+ script=gametime.schedule(at_sunrise,repeat=True,hour=6,min=0,sec=0)
+ script.key="at sunrise"
+
+
+
If you want to test this function, you can easily do something like:
The script will be created silently. The at_sunrise function will now be called every in-game day
+at 6 AM. You can use the @scripts command to see it. You could stop it using @scripts/stop. If
+we hadn’t set repeat the sun would only have risen once and then never again.
+
We used the @py command here: nothing prevents you from adding the system into your game code.
+Remember to be careful not to add each event at startup, however, otherwise there will be a lot of
+overlapping events scheduled when the sun rises.
+
The schedule function when using repeat set to True works with the higher, non-specified unit.
+In our example, we have specified hour, minute and second. The higher unit we haven’t specified is
+day: schedule assumes we mean “run the callback every day at the specified time”. Therefore, you
+can have an event that runs every hour at HH:30, or every month on the 3rd day.
+
+
A word of caution for repeated scripts on a monthly or yearly basis: due to the variations in the
+real-life calendar you need to be careful when scheduling events for the end of the month or year.
+For example, if you set a script to run every month on the 31st it will run in January but find no
+such day in February, April etc. Similarly, leap years may change the number of days in the year.
Using a custom calendar to handle game time is sometimes needed if you want to place your game in a
+fictional universe. For instance you may want to create the Shire calendar which Tolkien described
+having 12 months, each which 30 days. That would give only 360 days per year (presumably hobbits
+weren’t really fond of the hassle of following the astronomical calendar). Another example would be
+creating a planet in a different solar system with, say, days 29 hours long and months of only 18
+days.
+
Evennia handles custom calendars through an optional contrib module, called custom_gametime.
+Contrary to the normal gametime module described above it is not active by default.
In our first example of the Shire calendar, used by hobbits in books by Tolkien, we don’t really
+need the notion of weeks… but we need the notion of months having 30 days, not 28.
+
The custom calendar is defined by adding the TIME_UNITS setting to your settings file. It’s a
+dictionary containing as keys the name of the units, and as value the number of seconds (the
+smallest unit for us) in this unit. Its keys must be picked among the following: “sec”, “min”,
+“hour”, “day”, “week”, “month” and “year” but you don’t have to include them all. Here is the
+configuration for the Shire calendar:
+
# in a file settings.py in mygame/server/conf
+TIME_UNITS={"sec":1,
+ "min":60,
+ "hour":60*60,
+ "day":60*60*24,
+ "month":60*60*24*30,
+ "year":60*60*24*30*12}
+
+
+
We give each unit we want as keys. Values represent the number of seconds in that unit. Hour is
+set to 60 * 60 (that is, 3600 seconds per hour). Notice that we don’t specify the week unit in this
+configuration: instead, we skip from days to months directly.
+
In order for this setting to work properly, remember all units have to be multiples of the previous
+units. If you create “day”, it needs to be multiple of hours, for instance.
+
So for our example, our settings may look like this:
+
# in a file settings.py in mygame/server/conf
+# Time factor
+TIME_FACTOR=4
+
+# Game time epoch
+TIME_GAME_EPOCH=0
+
+# Units
+TIME_UNITS={
+ "sec":1,
+ "min":60,
+ "hour":60*60,
+ "day":60*60*24,
+ "month":60*60*24*30,
+ "year":60*60*24*30*12,
+}
+
+
+
Notice we have set a time epoch of 0. Using a custom calendar, we will come up with a nice display
+of time on our own. In our case the game time starts at year 0, month 0, day 0, and at midnight.
+
Note that while we use “month”, “week” etc in the settings, your game may not use those terms in-
+game, instead referring to them as “cycles”, “moons”, “sand falls” etc. This is just a matter of you
+displaying them differently. See next section.
As pointed out earlier, the @time command is meant to be used with a standard calendar, not a
+custom one. We can easily create a new command though. We’ll call it time, as is often the case
+on other MU*. Here’s an example of how we could write it (for the example, you can create a file
+showtime.py in your commands directory and paste this code in it):
+
# in a file mygame/commands/gametime.py
+
+fromevennia.contribimportcustom_gametime
+
+fromcommands.commandimportCommand
+
+classCmdTime(Command):
+
+ """
+ Display the time.
+
+ Syntax:
+ time
+
+ """
+
+ key="time"
+ locks="cmd:all()"
+
+ deffunc(self):
+ """Execute the time command."""
+ # Get the absolute game time
+ year,month,day,hour,min,sec=custom_gametime.custom_gametime(absolute=True)
+ string="We are in year {year}, day {day}, month {month}."
+ string+="\nIt's {hour:02}:{min:02}:{sec:02}."
+ self.msg(string.format(year=year,month=month,day=day,
+ hour=hour,min=min,sec=sec))
+
+
+
Don’t forget to add it in your CharacterCmdSet to see this command:
+
# in mygame/commands/default_cmdset.py
+
+fromcommands.gametimeimportCmdTime# <-- Add
+
+# ...
+
+classCharacterCmdSet(default_cmds.CharacterCmdSet):
+ """
+ The `CharacterCmdSet` contains general in-game commands like `look`,
+ `get`, etc available on in-game Character objects. It is merged with
+ the `AccountCmdSet` when an Account puppets a Character.
+ """
+ key="DefaultCharacter"
+
+ defat_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ super().at_cmdset_creation()
+ # ...
+ self.add(CmdTime())# <- Add
+
+
+
Reload your game with the @reload command. You should now see the time command. If you enter
+it, you might see something like:
+
We are in year 0, day 0, month 0.
+It's 00:52:17.
+
+
+
You could display it a bit more prettily with names for months and perhaps even days, if you want.
+And if “months” are called “moons” in your game, this is where you’d add that.
The custom_gametime module also has a way to schedule game-related events, taking into account
+your game time (and your custom calendar). It can be used to have a specific message every day at
+6:00 AM, to show the sun rises, for instance. The custom_gametime.schedule function works in the
+same way as described for the default one above.
This will help you download, install and start Evennia for the first time.
+
+
Note: You don’t need to make anything visible to the ‘net in order to run and
+test out Evennia. Apart from downloading and updating you don’t even need an
+internet connection until you feel ready to share your game with the world.
evenniastart (make sure to make a superuser when asked)
+Evennia should now be running and you can connect to it by pointing a web browser to
+http://localhost:4001 or a MUD telnet client to localhost:4000 (use 127.0.0.1 if your OS does
+not recognize localhost).
+
+
We also release Docker images
+based on master and develop branches.
If you run into any issues during the installation and first start, please
+check out Linux Troubleshooting.
+
For Debian-derived systems (like Ubuntu, Mint etc), start a terminal and
+install the dependencies:
+
sudoapt-getupdate
+sudoapt-getinstallpython3python3-pippython3-devpython3-setuptoolspython3-git
+python3-virtualenvgcc
+
+# If you are using an Ubuntu version that defaults to Python3, like 18.04+, use this instead:
+sudoapt-getupdate
+sudoapt-getinstallpython3.7python3-pippython3.7-devpython3-setuptoolsvirtualenvgcc
+
+
+
+
Note that, the default Python version for your distribution may still not be Python3.7 after this.
+This is ok - we’ll specify exactly which Python to use later.
+You should make sure to not be root after this step, running as root is a
+security risk. Now create a folder where you want to do all your Evennia
+development:
+
mkdirmuddev
+cdmuddev
+
+
+
Next we fetch Evennia itself:
+
gitclonehttps://github.com/evennia/evennia.git
+
+
+
A new folder evennia will appear containing the Evennia library. This only
+contains the source code though, it is not installed yet. To isolate the
+Evennia install and its dependencies from the rest of the system, it is good
+Python practice to install into a virtualenv. If you are unsure about what a
+virtualenv is and why it’s useful, see the Glossary entry on
+virtualenv.
+
Run python-V to see which version of Python your system defaults to.
+
# If your Linux defaults to Python3.7+:
+virtualenvevenv
+
+# If your Linux defaults to Python2 or an older version
+# of Python3, you must instead point to Python3.7+ explicitly:
+virtualenv-p/usr/bin/python3.7evenv
+
+
+
A new folder evenv will appear (we could have called it anything). This
+folder will hold a self-contained setup of Python packages without interfering
+with default Python packages on your system (or the Linux distro lagging behind
+on Python package versions). It will also always use the right version of Python.
+Activate the virtualenv:
+
sourceevenv/bin/activate
+
+
+
The text (evenv) should appear next to your prompt to show that the virtual
+environment is active.
+
+
Remember that you need to activate the virtualenv like this every time you
+start a new terminal to get access to the Python packages (notably the
+important evennia program) we are about to install.
+
+
Next, install Evennia into your active virtualenv. Make sure you are standing
+at the top of your mud directory tree (so you see the evennia/ and evenv/
+folders) and run
cdmygame
+evenniamigrate# (this creates the database)
+evenniastart# (create a superuser when asked. Email is optional.)
+
+
+
+
Server logs are found in mygame/server/logs/. To easily view server logs
+live in the terminal, use evennia-l (exit the log-view with Ctrl-C).
+
+
Your game should now be running! Open a web browser at http://localhost:4001
+or point a telnet client to localhost:4000 and log in with the user you
+created. Check out where to go next.
The Evennia server is a terminal program. Open the terminal e.g. from
+Applications->Utilities->Terminal. Here is an introduction to the Mac
+terminal
+if you are unsure how it works. If you run into any issues during the
+installation, please check out Mac Troubleshooting.
+
+
Python should already be installed but you must make sure it’s a high enough version.
+(This discusses
+how you may upgrade it). Remember that you need Python3.7, not Python2.7!
If you run into issues with installing Twisted later you may need to
+install gcc and the Python headers.
+
+
After this point you should not need sudo or any higher privileges to install anything.
+
Now create a folder where you want to do all your Evennia development:
+
mkdirmuddev
+cdmuddev
+
+
+
Next we fetch Evennia itself:
+
gitclonehttps://github.com/evennia/evennia.git
+
+
+
A new folder evennia will appear containing the Evennia library. This only
+contains the source code though, it is not installed yet. To isolate the
+Evennia install and its dependencies from the rest of the system, it is good
+Python practice to install into a virtualenv. If you are unsure about what a
+virtualenv is and why it’s useful, see the Glossary entry on virtualenv.
+
Run python-V to check which Python your system defaults to.
+
# If your Mac defaults to Python3:
+virtualenvevenv
+
+# If your Mac defaults to Python2 you need to specify the Python3.7 binary explicitly:
+virtualenv-p/path/to/your/python3.7evenv
+
+
+
A new folder evenv will appear (we could have called it anything). This
+folder will hold a self-contained setup of Python packages without interfering
+with default Python packages on your system. Activate the virtualenv:
+
sourceevenv/bin/activate
+
+
+
The text (evenv) should appear next to your prompt to show the virtual
+environment is active.
+
+
Remember that you need to activate the virtualenv like this every time you
+start a new terminal to get access to the Python packages (notably the
+important evennia program) we are about to install.
+
+
Next, install Evennia into your active virtualenv. Make sure you are standing
+at the top of your mud directory tree (so you see the evennia/ and evenv/
+folders) and run
+
pipinstall--upgradepip# Old pip versions may be an issue on Mac.
+pipinstall--upgradesetuptools# Ditto concerning Mac issues.
+pipinstall-eevennia
+
cdmygame
+evenniamigrate# (this creates the database)
+evenniastart# (create a superuser when asked. Email is optional.)
+
+
+
+
Server logs are found in mygame/server/logs/. To easily view server logs
+live in the terminal, use evennia-l (exit the log-view with Ctrl-C).
+
+
Your game should now be running! Open a web browser at http://localhost:4001
+or point a telnet client to localhost:4000 and log in with the user you
+created. Check out where to go next.
If you run into any issues during the installation, please check out
+Windows Troubleshooting.
+
+
If you are running Windows10, consider using the Windows Subsystem for Linux
+(WSL) instead.
+You should then follow the Linux install instructions above.
+
+
The Evennia server itself is a command line program. In the Windows launch
+menu, start All Programs -> Accessories -> command prompt and you will get
+the Windows command line interface. Here is one of many tutorials on using the Windows command
+line
+if you are unfamiliar with it.
+
+
Install Python from the Python homepage. You will
+need to be a
+Windows Administrator to install packages. You want Python version 3.7.0 (latest verified
+version), usually
+the 64-bit version (although it doesn’t matter too much). When installing, make sure
+to check-mark all install options, especially the one about making Python
+available on the path (you may have to scroll to see it). This allows you to
+just write python in any console without first finding where the python
+program actually sits on your hard drive.
+
You need to also get GIT and install it. You
+can use the default install options but when you get asked to “Adjust your PATH
+environment”, you should select the second option “Use Git from the Windows
+Command Prompt”, which gives you more freedom as to where you can use the
+program.
+
Finally you must install the Microsoft Visual C++ compiler for
+Python. Download and run the linked installer and
+install the C++ tools. Keep all the defaults. Allow the install of the “Win10 SDK”, even if you are
+on Win7 (not tested on older Windows versions). If you later have issues with installing Evennia due
+to a failure to build the “Twisted wheels”, this is where you are missing things.
+
You may need the pypiwin32 Python headers. Install
+these only if you have issues.
+
+
You can install Evennia wherever you want. cd to that location and create a
+new folder for all your Evennia development (let’s call it muddev).
+
mkdirmuddev
+cdmuddev
+
+
+
+
Hint: If cd isn’t working you can use pushd instead to force the
+directory change.
+
+
Next we fetch Evennia itself:
+
gitclonehttps://github.com/evennia/evennia.git
+
+
+
A new folder evennia will appear containing the Evennia library. This only
+contains the source code though, it is not installed yet. To isolate the
+Evennia install and its dependencies from the rest of the system, it is good
+Python practice to install into a virtualenv. If you are unsure about what a
+virtualenv is and why it’s useful, see the Glossary entry on virtualenv.
+
In your console, try python-V to see which version of Python your system
+defaults to.
+
pipinstallvirtualenv
+
+# If your setup defaults to Python3.7:
+virtualenvevenv
+
+# If your setup defaults to Python2, specify path to python3.exe explicitly:
+virtualenv-pC:\Python37\python.exeevenv
+
+# If you get an infinite spooling response, press CTRL + C to interrupt and try using:
+python-mvenvevenv
+
+
+
+
A new folder evenv will appear (we could have called it anything). This
+folder will hold a self-contained setup of Python packages without interfering
+with default Python packages on your system. Activate the virtualenv:
+
# If you are using a standard command prompt, you can use the following:
+evenv\scripts\activate.bat
+
+# If you are using a PS Shell, Git Bash, or other, you can use the following:
+.\evenv\scripts\activate
+
+
+
+
The text (evenv) should appear next to your prompt to show the virtual
+environment is active.
+
+
Remember that you need to activate the virtualenv like this every time you
+start a new console window if you want to get access to the Python packages
+(notably the important evennia program) we are about to install.
+
+
Next, install Evennia into your active virtualenv. Make sure you are standing
+at the top of your mud directory tree (so you see the evennia and evenv
+folders when you use the dir command) and run
+
pipinstall-eevennia
+
+
+
For more info about pip, see the Glossary entry on pip. If
+the install failed with any issues, see [Windows Troubleshooting](./Getting-Started.md#windows-
+troubleshooting).
+Next we’ll start our new game, we’ll call it “mygame” here. This creates a new folder where you will
+be
+creating your new game:
+
evennia--initmygame
+
+
+
Your final folder structure should look like this:
cdmygame
+evenniamigrate# (this creates the database)
+evenniastart# (create a superuser when asked. Email is optional.)
+
+
+
+
Server logs are found in mygame/server/logs/. To easily view server logs
+live in the terminal, use evennia-l (exit the log-view with Ctrl-C).
+
+
Your game should now be running! Open a web browser at http://localhost:4001
+or point a telnet client to localhost:4000 and log in with the user you
+created. Check out where to go next.
Welcome to Evennia! Your new game is fully functioning, but empty. If you just
+logged in, stand in the Limbo room and run
+
@batchcommand tutorial_world.build
+
+
+
to build Evennia’s tutorial world - it’s a small solo quest to
+explore. Only run the instructed @batchcommand once. You’ll get a lot of text scrolling by as the
+tutorial is built. Once done, the tutorial exit will have appeared out of Limbo - just write
+tutorial to enter it.
+
Once you get back to Limbo from the tutorial (if you get stuck in the tutorial quest you can do
+@tel#2 to jump to Limbo), a good idea is to learn how to [start, stop and reload](Start-Stop-
+Reload) the Evennia server. You may also want to familiarize yourself with some commonly used terms
+in our Glossary. After that, why not experiment with creating some new items and build
+some new rooms out from Limbo.
+
From here on, you could move on to do one of our introductory tutorials or simply dive
+headlong into Evennia’s comprehensive manual. While
+Evennia has no major game systems out of the box, we do supply a range of optional contribs that
+you can use or borrow from. They range from dice rolling and alternative color schemes to barter and
+combat systems. You can find the growing list of contribs
+here.
If you have issues with installing or starting Evennia for the first time,
+check the section for your operating system below. If you have an issue not
+covered here, please report it
+so it can be fixed or a workaround found!
+
Remember, the server logs are in mygame/server/logs/. To easily view server logs in the terminal,
+you can run evennia-l, or (in the future) start the server with evenniastart-l.
If you get an error when installing Evennia (especially with lines mentioning
+failing to include Python.h) then try sudoapt-getinstallpython3-setuptoolspython3-dev.
+Once installed, run pipinstall-eevennia again.
+
Under some not-updated Linux distributions you may run into errors with a
+too-old setuptools or missing functools. If so, update your environment
+with pipinstall--upgradepipwheelsetuptools. Then try pipinstall-eevennia again.
+
If you get an setup.pynotfound error message while trying to pipinstall, make sure you are
+in the right directory. You should be at the same level of the evenv directory, and the
+evennia git repository. Note that there is an evennia directory inside of the repository too.
+
One user reported a rare issue on Ubuntu 16 is an install error on installing Twisted; Command"pythonsetup.pyegg_info"failedwitherrorcode1in/tmp/pip-build-vnIFTg/twisted/ with errors
+like distutils.errors.DistutilsError:CouldnotfindsuitabledistributionforRequirement.parse('incremental>=16.10.1'). This appears possible to solve by simply updating Ubuntu
+with sudoapt-getupdate&&sudoapt-getdist-upgrade.
+
Users of Fedora (notably Fedora 24) has reported a gcc error saying the directory
+/usr/lib/rpm/redhat/redhat-hardened-cc1 is missing, despite gcc itself being installed. The
+confirmed work-around seems to be to
+install the redhat-rpm-config package with e.g. sudodnfinstallredhat-rpm-config.
+
Some users trying to set up a virtualenv on an NTFS filesystem find that it fails due to issues
+with symlinks not being supported. Answer is to not use NTFS (seriously, why would you do that to
+yourself?)
Mac users have reported a critical MemoryError when trying to start Evennia on Mac with a Python
+version below 2.7.12. If you get this error, update to the latest XCode and Python2 version.
+
Some Mac users have reported not being able to connect to localhost (i.e. your own computer). If
+so, try to connect to 127.0.0.1 instead, which is the same thing. Use port 4000 from mud clients
+and port 4001 from the web browser as usual.
If you installed Python but the python command is not available (even in a new console), then
+you might have missed installing Python on the path. In the Windows Python installer you get a list
+of options for what to install. Most or all options are pre-checked except this one, and you may
+even have to scroll down to see it. Reinstall Python and make sure it’s checked.
+
If your MUD client cannot connect to localhost:4000, try the equivalent 127.0.0.1:4000
+instead. Some MUD clients on Windows does not appear to understand the alias localhost.
+
If you run virtualenvevenv and get a 'virtualenv'isnotrecognizedasaninternalorexternalcommand,operableprogramorbatchfile. error, you can mkdirevenv, cdevenv and then python-mvirtualenv. as a workaround.
+
Some Windows users get an error installing the Twisted ‘wheel’. A wheel is a pre-compiled binary
+package for Python. A common reason for this error is that you are using a 32-bit version of Python,
+but Twisted has not yet uploaded the latest 32-bit wheel. Easiest way to fix this is to install a
+slightly older Twisted version. So if, say, version 18.1 failed, install 18.0 manually with pipinstalltwisted==18.0. Alternatively you could try to get a 64-bit version of Python (uninstall the
+32bit one). If so, you must then deactivate the virtualenv, delete the evenv folder and recreate
+it anew (it will then use the new Python executable).
+
If your server won’t start, with no error messages (and no log files at all when starting from
+scratch), try to start with evenniaipstart instead. If you then see an error about systemcannotfindthepathspecified, it may be that the file evennia/evennia/server/twistd.bat has the wrong
+path to the twistd executable. This file is auto-generated, so try to delete it and then run
+evenniastart to rebuild it and see if it works. If it still doesn’t work you need to open it in a
+text editor like Notepad. It’s just one line containing the path to the twistd.exe executable as
+determined by Evennia. If you installed Twisted in a non-standard location this might be wrong and
+you should update the line to the real location.
+
Some users have reported issues with Windows WSL and anti-virus software during Evennia
+development. Timeout errors and the inability to run evenniaconnections may be due to your anti-
+virus software interfering. Try disabling or changing your anti-virus software settings.
The term ‘account’ refers to the player’s unique account on the game. It is
+represented by the Accounttypeclass and holds things like email, password,
+configuration etc.
+
When a player connects to the game, they connect to their account. The account has no
+representation in the game world. Through their Account they can instead choose to
+puppet one (or more, depending on game mode) Characters in
+the game.
+
In the default multisession mode of Evennia, you immediately start
+puppeting a Character with the same name as your Account when you log in - mimicking how older
+servers used to work.
This usually refers to Django’sAdmin site or database-administration web page
+(link to Django docs). The admin site is
+an automatically generated web interface to the database (it can be customized extensively). It’s
+reachable from the admin link on the default Evennia website you get with your server.
The term Attribute should not be confused with (properties or
+fields. The Attribute represents arbitrary pieces of data that can be attached
+to any typeclassed entity in Evennia. Attributes allows storing new persistent
+data on typeclasses without changing their underlying database schemas. Read more about Attributes
+here.
A Channel refers to an in-game communication channel. It’s an entity that people subscribe to and
+which re-distributes messages between all subscribers. Such subscribers default to being
+Accounts, for out-of-game communication but could also be Objects (usually
+Characters) if one wanted to adopt Channels for things like in-game walkie-
+talkies or phone systems. It is represented by the Channel typeclass. You can read more about the
+comm system here.
The Character is the term we use for the default avatar being puppeted by the
+account in the game world. It is represented by the Character typeclass (which
+is a child of Object). Many developers use children of this class to represent
+monsters and other NPCs. You can read more about it here.
Django is a professional and very popular Python web framework,
+similar to Rails for the Ruby language. It is one of Evennia’s central library dependencies (the
+other one is Twisted). Evennia uses Django for two main things - to map all
+database operations to Python and for structuring our web site.
+
Through Django, we can work with any supported database (SQlite3, Postgres, MySQL …) using generic
+Python instead of database-specific SQL: A database table is represented in Django as a Python class
+(called a model). An Python instance of such a class represents a row in that table.
+
There is usually no need to know the details of Django’s database handling in order to use Evennia -
+it will handle most of the complexity for you under the hood using what we call
+typeclasses. But should you need the power of Django you can always get it.
+Most commonly people want to use “raw” Django when doing more advanced/custom database queries than
+offered by Evennia’s default search functions. One will then need
+to read about Django’s querysets. Querysets are Python method calls on a special form that lets
+you build complex queries. They get converted into optimized SQL queries under the hood, suitable
+for your current database. [Here is our tutorial/explanation of Django queries](Tutorial-Searching-
+For-Objects#queries-in-django).
+
+
By the way, Django (and Evennia) does allow you to fall through and send raw SQL if you really
+want to. It’s highly unlikely to be needed though; the Django database abstraction is very, very
+powerful.
+
+
The other aspect where Evennia uses Django is for web integration. On one end Django gives an
+infrastructure for wiring Python functions (called views) to URLs: the view/function is called
+when a user goes that URL in their browser, enters data into a form etc. The return is the web page
+to show. Django also offers templating with features such as being able to add special markers in
+HTML where it will insert the values of Python variables on the fly (like showing the current player
+count on the web page). [Here is one of our tutorials on wiring up such a web page](Add-a-simple-
+new-web-page). Django also comes with the admin site, which automatically
+maps the database into a form accessible from a web browser.
Contribs are optional and often more game-specific code-snippets contributed by the Evennia community.
+They are distributed with Evennia in the contrib/ folder.
This term is sometimes used to represent the main Evennia library code suite, excluding its
+contrib directory. It can sometimes come up in code reviews, such as
+
+
Evennia is game-agnostic but this feature is for a particular game genre. So it does not belong in
+core. Better make it a contrib.
A field or database field in Evennia refers to a property on a
+typeclass directly linked to an underlying database column. Only a few fixed
+properties per typeclass are database fields but they are often tied to the core functionality of
+that base typeclass (for example Objects store its location as a field). In all
+other cases, attributes are used to add new persistent data to the typeclass.
+Read more about typeclass properties here.
Git is a version control
+tool. It allows us to track the development of the Evennia code by dividing it into units called
+commits. A ‘commit’ is sort of a save-spot - you save the current state of your code and can then
+come back to it later if later changes caused problems. By tracking commits we know what ‘version’
+of the code we are currently using.
+
Evennia’s source code + its source history is jointly called a repository.
+This is centrally stored at our online home on GitHub. Everyone using or
+developing Evennia makes a ‘clone’ of this repository to their own computer - everyone
+automatically gets everything that is online, including all the code history.
+
+
Don’t confuse Git and GitHub. The former is the version control system. The
+latter is a website (run by a company) that allows you to upload source code controlled by Git for
+others to see (among other things).
+
+
Git allows multiple users from around the world to efficiently collaborate on Evennia’s code: People
+can make local commits on their cloned code. The commits they do can then be uploaded to GitHub and
+reviewed by the Evennia lead devs - and if the changes look ok they can be safely merged into the
+central Evennia code - and everyone can pull those changes to update their local copies.
+
Developers using Evennia often uses Git on their own games in the same way - to track their changes
+and to help collaboration with team mates. This is done completely independently of Evennia’s Git
+usage.
+
Common usage (for non-Evennia developers):
+
+
gitclone<github-url> - clone an online repository to your computer. This is what you do when
+you ‘download’ Evennia. You only need to do this once.
+
gitpull (inside local copy of repository) - sync your local repository with what is online.
+
+
+
Full usage of Git is way beyond the scope of this glossary. See Tutorial - version
+control for more info and links to the Git documentation.
This term is used for upgrading the database structure (it’s schema )to a new version. Most often
+this is due to Evennia’s upstream schema changing. When that happens you need to
+migrate that schema to the new version as well. Once you have used git to pull the
+latest changes, just cd into your game dir and run
+
evennia migrate
+
+
+
That should be it (see virtualenv if you get a warning that the evennia
+command is not available). See also Updating your game for more details.
+
+
Technically, migrations are shipped as little Python snippets of code that explains which database
+actions must be taken to upgrade from one version of the schema to the next. When you run the
+command above, those snippets are run in sequence.
This term refers to the MULTISESSION_MODE setting, which has a value of 0 to 3. The mode alters
+how players can connect to the game, such as how many Sessions a player can start with one account
+and how many Characters they can control at the same time. It is described in detail
+here.
Github is where Evennia’s source code and documentation is hosted.
+This online repository of code we also sometimes refer to as upstream.
+
GitHub is a business, offering free hosting to Open-source projects like Evennia. Despite the
+similarity in name, don’t confuse GitHub the website with Git, the versioning
+system. Github hosts Git repositories online and helps with collaboration and
+infrastructure. Git itself is a separate project.
In general Python (and other object-oriented languages), an object is what we call the instance of a class. But one of Evennia’s
+core typeclasses is also called “Object”. To separate these in the docs we
+try to use object to refer to the general term and capitalized Object when we refer to the
+typeclass.
pip comes with Python and is the main tool for installing third-
+party Python packages from the web. Once a python package is installed you can do import<packagename> in your Python code.
+
Common usage:
+
+
pipinstall<package-name> - install the given package along with all its dependencies.
+
pipsearch<name> - search Python’s central package repository PyPi for a
+package of that name.
+
pipinstall--upgrade<package_name> - upgrade a package you already have to the latest version.
+
pipinstall<packagename>==1.5 - install exactly a specific package version.
+
pipinstall<folder> - install a Python package you have downloaded earlier (or cloned using
+git).
+
pipinstall-e<folder> - install a local package by just making a soft link to the folder. This
+means that if the code in <folder> changes, the installed Python package is immediately updated.
+If not using -e, one would need to run pipinstall--upgrade<folder> every time to make the
+changes available when you import this package into your code. Evennia is installed this way.
+
+
For development, pip is usually used together with a virtualenv to install
+all packages and dependencies needed for a project in one, isolated location on the hard drive.
An account can take control and “play as” any Object. When
+doing so, we call this puppeting, (like puppeteering).
+Normally the entity being puppeted is of the Character subclass but it does
+not have to be.
A property is a general term used for properties on any Python object. The term also sometimes
+refers to the property built-in function of Python (read more here). Note the distinction between properties,
+fields and Attributes.
A repository is a version control/git term. It represents a folder containing
+source code plus its versioning history.
+
+
In Git’s case, that history is stored in a hidden folder .git. If you ever feel the need to look
+into this folder you probably already know enough Git to know why.
+
+
The evennia folder you download from us with gitclone is a repository. The code on
+GitHub is often referred to as the ‘online repository’ (or the upstream
+repository). If you put your game dir under version control, that of course becomes a repository as
+well.
When we refer to Scripts, we generally refer to the Scripttypeclass. Scripts are
+the mavericks of Evennia - they are like Objects but without any in-game
+existence. They are useful as custom places to store data but also as building blocks in persistent
+game systems. Since the can be initialized with timing capabilities they can also be used for long-
+time persistent time keeping (for fast updates other types of timers may be better though). Read
+more about Scripts here
A Session is a Python object representing a single client connection to the server. A
+given human player could connect to the game from different clients and each would get a Session
+(even if you did not allow them to actually log in and get access to an
+account).
+
Sessions are nottypeclassed and has no database persistence. But since they
+always exist (also when not logged in), they share some common functionality with typeclasses that
+can be useful for certain game states.
A Tag is a simple label one can attach to one or more objects in the game. Tagging is a
+powerful way to group entities and can also be used to indicate they have particular shared abilities.
+Tags are shared between objects (unlike Attributes).
The Ticker handler runs Evennia’s optional ‘ticker’ system. In other engines, such
+as DIKU, all game events are processed only at specific
+intervals called ‘ticks’. Evennia has no such technical limitation (events are processed whenever
+needed) but using a fixed tick can still be useful for certain types of game systems, like combat.
+Ticker Handler allows you to emulate any number of tick rates (not just one) and subscribe actions
+to be called when those ticks come around.
The typeclass is an Evennia-specific term. A typeclass allows developers to work with
+database-persistent objects as if they were normal Python objects. It makes use of specific
+Django features to link a Python class to a database table. Sometimes we refer to
+such code entities as being typeclassed.
+
Evennia’s main typeclasses are Account, Object,
+Script and Channel. Children of the base class (such as
+Character) will use the same database table as the parent, but can have vastly
+different Python capabilities (and persistent features through Attributes and
+Tags. A typeclass can be coded and treated pretty much like any other Python class
+except it must inherit (at any distance) from one of the base typeclasses. Also, creating a new
+instance of a typeclass will add a new row to the database table to which it is linked.
+
The core typeclasses in the Evennia library are all named DefaultAccount,
+DefaultObject etc. When you initialize your [game dir] you automatically get empty children of
+these, called Account, Object etc that you can start working with.
Twisted is a heavy-duty asynchronous networking engine. It is one
+of Evennia’s two major library dependencies (the other one is Django). Twisted is
+what “runs” Evennia - it handles Evennia’s event loop. Twisted also has the building blocks we need
+to construct network protocols and communicate with the outside world; such as our MUD-custom
+version of Telnet, Telnet+SSL, SSH, webclient-websockets etc. Twisted also runs our integrated web
+server, serving the Django-based website for your game.
The standard virtualenv program comes with Python. It is
+used to isolate all Python packages needed by a given Python project into one folder (we call that
+folder evenv but it could be called anything). A package environment created this way is usually
+referred to as “a virtualenv”. If you ever try to run the evennia program and get an error saying
+something like “the command ‘evennia’ is not available” - it’s probably because your virtualenv is
+not ‘active’ yet (see below).
+
Usage:
+
+
virtualenv<name> - initialize a new virtualenv <name> in a new folder <name> in the current
+location. Called evenv in these docs.
+
virtualenv-ppath/to/alternate/python_executable<name> - create a virtualenv using another
+Python version than default.
+
source<folder_name>/bin/activate(linux/mac) - activate the virtualenv in <folder_name>.
+
<folder_name>\Scripts\activate (windows)
+
deactivate - turn off the currently activated virtualenv.
+
+
A virtualenv is ‘activated’ only for the console/terminal it was started in, but it’s safe to
+activate the same virtualenv many times in different windows if you want. Once activated, all Python
+packages now installed with pip will install to evenv rather than to a global
+location like /usr/local/bin or C:\ProgramFiles.
+
+
Note that if you have root/admin access you could install Evennia globally just fine, without
+using a virtualenv. It’s strongly discouraged and considered bad practice though. Experienced Python
+developers tend to rather create one new virtualenv per project they are working on, to keep the
+varying installs cleanly separated from one another.
+
+
When you execute Python code within this activated virtualenv, only those packages installed
+within will be possible to import into your code. So if you installed a Python package globally on
+your computer, you’ll need to install it again in your virtualenv.
+
+
Virtualenvs only deal with Python programs/packages. Other programs on your computer couldn’t
+care less if your virtualenv is active or not. So you could use git without the virtualenv being
+active, for example.
+
+
When your virtualenv is active you should see your console/terminal prompt change to
+
(evenv) ...
+
+
+
… or whatever name you gave the virtualenv when you initialized it.
+
+
We sometimes say that we are “in” the virtualenv when it’s active. But just to be clear - you
+never have to actually cd into the evenv folder. You can activate it from anywhere and will
+still be considered “in” the virtualenv wherever you go until you deactivate or close the
+console/terminal.
+
+
So, when do I need to activate my virtualenv? If the virtualenv is not active, none of the Python
+packages/programs you installed in it will be available to you. So at a minimum, it needs to be
+activated whenever you want to use the evennia command for any reason.
Grapevine is a new chat network for MU**** games. By
+connecting an in-game channel to the grapevine network, players on your game
+can chat with players in other games, also non-Evennia ones.
To use Grapevine, you first need the pyopenssl module. Install it into your
+Evennia python environment with
+
pip install pyopenssl
+
+
+
To configure Grapevine, you’ll need to activate it in your settings file.
+
GRAPEVINE_ENABLED=True
+
+
+
Next, register an account at https://grapevine.haus. When you have logged in,
+go to your Settings/Profile and to the Games sub menu. Here you register your
+new game by filling in its information. At the end of registration you are going
+to get a ClientID and a ClientSecret. These should not be shared.
+
Open/create the file mygame/server/conf/secret_settings.py and add the following:
You can also customize the Grapevine channels you are allowed to connect to. This
+is added to the GRAPEVINE_CHANNELS setting. You can see which channels are available
+by going to the Grapevine online chat here: https://grapevine.haus/chat.
+
Start/reload Evennia and log in as a privileged user. You should now have a new
+command available: @grapevine2chan. This command is called like this:
Here, the evennia_channel must be the name of an existing Evennia channel and
+grapevine_channel one of the supported channels in GRAPEVINE_CHANNELS.
+
+
At the time of writing, the Grapevine network only has two channels:
+testing and gossip. Evennia defaults to allowing connecting to both. Use
+testing for trying your connection.
You can connect Grapevine to any Evennia channel (so you could connect it to
+the default public channel if you like), but for testing, let’s set up a
+new channel gw.
+
@ccreate gw = This is connected to an gw channel!
+
+
+
You will automatically join the new channel.
+
Next we will create a connection to the Grapevine network.
+
@grapevine2chan gw = gossip
+
+
+
Evennia will now create a new connection and connect it to Grapevine. Connect
+to https://grapevine.haus/chat to check.
+
Write something in the Evennia channel gw and check so a message appears in
+the Grapevine chat. Write a reply in the chat and the grapevine bot should echo
+it to your channel in-game.
+
Your Evennia gamers can now chat with users on external Grapevine channels!
Evennia supports guest logins out of the box. A guest login is an anonymous, low-access account
+and can be useful if you want users to have a chance to try out your game without committing to
+creating a real account.
+
Guest accounts are turned off by default. To activate, add this to your game/settings.py file:
+
GUEST_ENABLED = True
+
+
+
Henceforth users can use connectguest (in the default command set) to login with a guest account.
+You may need to change your Connection Screen to inform them of this
+possibility. Guest accounts work differently from normal accounts - they are automatically deleted
+whenever the user logs off or the server resets (but not during a reload). They are literally re-
+usable throw-away accounts.
+
You can add a few more variables to your settings.py file to customize your guests:
+
+
BASE_GUEST_TYPECLASS - the python-path to the default typeclass for guests.
+Defaults to "typeclasses.accounts.Guest".
+
PERMISSION_GUEST_DEFAULT - permission level for guest accounts. Defaults to "Guests",
+which is the lowest permission level in the hierarchy.
+
GUEST_START_LOCATION - the #dbref to the starting location newly logged-in guests should
+appear at. Defaults to "#2 (Limbo).
+
GUEST_HOME - guest home locations. Defaults to Limbo as well.
+
GUEST_LIST - this is a list holding the possible guest names to use when entering the game. The
+length of this list also sets how many guests may log in at the same time. By default this is a list
+of nine names from "Guest1" to "Guest9".
Making Evennia, HTTPS and Secure Websockets play nicely together¶
+
This we can do by installing a proxy between Evennia and the outgoing ports of your server.
+Essentially,
+Evennia will think it’s only running locally (on localhost, IP 127.0.0.1) - the proxy will
+transparently
+map that to the “real” outgoing ports and handle HTTPS/WSS for us.
Here we will use HAProxy, an open-source proxy that is easy to set up
+and use. We will
+also be using LetsEncrypt, especially the excellent
+helper-program Certbot which pretty much automates the whole
+certificate setup process for us.
+
Before starting you also need the following:
+
+
(optional) The host name of your game (like myawesomegame.com). This is something you must
+previously have purchased from a domain registrar and set up with DNS to point to the IP of your
+server.
+
If you don’t have a domain name or haven’t set it up yet, you must at least know the IP of your
+server. Find this with ifconfig or similar from inside the server. If you use a hosting service
+like DigitalOcean you can also find the droplet’s IP address in the control panel.
+
You must open port 80 in your firewall. This is used by Certbot below to auto-renew certificates.
+So you can’t really run another webserver alongside this setup without tweaking.
+
You must open port 443 (HTTPS) in your firewall.
+
You must open port 4002 (the default Websocket port) in your firewall.
Certificates guarantee that you are you. Easiest is to get this with
+Letsencrypt and the
+Certbot program. Certbot has a lot of install instructions
+for various operating systems. Here’s for Debian/Ubuntu:
+
sudoaptinstallcertbot
+
+
+
Make sure to stop Evennia and that no port-80 using service is running, then
+
sudocertbotcertonly--standalone
+
+
+
You will get some questions you need to answer, such as an email to send certificate errors to and
+the host name (or IP, supposedly) to use with this certificate. After this, the certificates will
+end up in /etc/letsencrypt/live/<your-host-or-ip>/*pem (example from Ubuntu). The critical files
+for our purposes are fullchain.pem and privkey.pem.
+
Certbot sets up a cron-job/systemd job to regularly renew the certificate. To check this works, try
+
sudocertbotrenew--dry-run
+
+
+
The certificate is only valid for 3 months at a time, so make sure this test works (it requires port
+80 to be open). Look up Certbot’s page for more help.
+
We are not quite done. HAProxy expects these two files to be one file.
This will create a new .pem file by concatenating the two files together. The yourhostname.pem
+file (or whatever you named it) is what we will use when the the HAProxy config file (below) asks
+for “your-certificate.pem”.
Make sure to reboot (stop + start) evennia completely:
+
evenniareboot
+
+
+
Finally you start the proxy:
+
sudohaproxy-f/path/to/the/above/config_file.cfg
+
+
+
Make sure you can connect to your game from your browser and that you end up with an https:// page
+and can use the websocket webclient.
+
Once everything works you may want to start the proxy automatically and in the background. Stop the
+proxy with Ctrl-C and uncomment the line #daemon in the config file, then start the proxy again
+
+
it will now start in the bacground.
+
+
You may also want to have the proxy start automatically; this you can do with cron, the inbuilt
+Linux mechanism for running things at specific times.
+
sudocrontab-e
+
+
+
Choose your editor and add a new line at the end of the crontab file that opens:
Before doing this tutorial you will probably want to read the intro in Basic Web tutorial. Reading the three first parts of the Django tutorial might help as well.
+
This tutorial will show you how to access the help system through your website. Both help commands
+and regular help entries will be visible, depending on the logged-in user or an anonymous character.
+
This tutorial will show you how to:
+
+
Create a new page to add to your website.
+
Take advantage of a basic view and basic templates.
+
Access the help system on your website.
+
Identify whether the viewer of this page is logged-in and, if so, to what account.
The first step is to create our new Django app. An app in Django can contain pages and
+mechanisms: your website may contain different apps. Actually, the website provided out-of-the-box
+by Evennia has already three apps: a “webclient” app, to handle the entire webclient, a “website”
+app to contain your basic pages, and a third app provided by Django to create a simple admin
+interface. So we’ll create another app in parallel, giving it a clear name to represent our help
+system.
+
From your game directory, use the following command:
+
evennia startapp help_system
+
+
+
+
Note: calling the app “help” would have been more explicit, but this name is already used by
+Django.
+
+
This will create a directory named help_system at the root of your game directory. It’s a good
+idea to keep things organized and move this directory in the “web” directory of your game. Your
+game directory should look like:
+
mygame/
+ ...
+ web/
+ help_system/
+ ...
+
+
+
The “web/help_system” directory contains files created by Django. We’ll use some of them, but if
+you want to learn more about them all, you should read the Django
+tutorial.
+
There is a last thing to be done: your folder has been added, but Django doesn’t know about it, it
+doesn’t know it’s a new app. We need to tell it, and we do so by editing a simple setting. Open
+your “server/conf/settings.py” file and add, or edit, these lines:
+
# Web configuration
+INSTALLED_APPS+=(
+ "web.help_system",
+)
+
+
+
You can start Evennia if you want, and go to your website, probably at
+http://localhost:4001 . You won’t see anything different though: we added
+the app but it’s fairly empty.
At this point, our new app contains mostly empty files that you can explore. In order to create
+a page for our help system, we need to add:
+
+
A view, dealing with the logic of our page.
+
A template to display our new page.
+
A new URL pointing to our page.
+
+
+
We could get away by creating just a view and a new URL, but that’s not a recommended way to work
+with your website. Building on templates is so much more convenient.
A view in Django is a simple Python function placed in the “views.py” file in your app. It will
+handle the behavior that is triggered when a user asks for this information by entering a URL (the
+connection between views and URLs will be discussed later).
+
So let’s create our view. You can open the “web/help_system/views.py” file and paste the following
+lines:
Our view handles all code logic. This time, there’s not much: when this function is called, it will
+render the template we will now create. But that’s where we will do most of our work afterward.
The render function called into our view asks the templatehelp_system/index.html. The
+templates of our apps are stored in the app directory, “templates” sub-directory. Django may have
+created the “templates” folder already. If not, create it yourself. In it, create another folder
+“help_system”, and inside of this folder, create a file named “index.html”. Wow, that’s some
+hierarchy. Your directory structure (starting from web) should look like this:
Here’s a little explanation line by line of what this template does:
+
+
It loads the “base.html” template. This describes the basic structure of all your pages, with
+a menu at the top and a footer, and perhaps other information like images and things to be present
+on each page. You can create templates that do not inherit from “base.html”, but you should have a
+good reason for doing so.
+
The “base.html” template defines all the structure of the page. What is left is to override
+some sections of our pages. These sections are called blocks. On line 2, we override the block
+named “blocktitle”, which contains the title of our page.
+
Same thing here, we override the block named “content”, which contains the main content of our
+web page. This block is bigger, so we define it on several lines.
+
This is perfectly normal HTML code to display a level-2 heading.
Last step to add our page: we need to add a URL leading to it… otherwise users won’t be able to
+access it. The URLs of our apps are stored in the app’s directory “urls.py” file.
+
Open the “web/help_system/urls.py” file (you might have to create it) and write in it:
+
# URL patterns for the help_system app
+
+fromdjango.urlsimportinclude,path
+fromweb.help_system.viewsimportindex
+
+urlpatterns=[
+ path(r'',index,name="help-index")
+]
+
+
+
We also need to add our app as a namespace holder for URLS. Edit the file “web/urls.py” (you might
+have to create this one too). In it you will find the custom_patterns variable. Replace it with:
When a user will ask for a specific URL on your site, Django will:
+
+
Read the list of custom patterns defined in “web/urls.py”. There’s one pattern here, which
+describes to Django that all URLs beginning by ‘help/’ should be sent to the ‘help_system’ app. The
+‘help/’ part is removed.
+
Then Django will check the “web.help_system/urls.py” file. It contains only one URL, which is
+empty.
+
+
In other words, if the URL is ‘/help/’, then Django will execute our defined view.
You can now reload or start Evennia. Open a tab in your browser and go to
+http://localhost:4001/help_system/ . If everything goes well,
+you should see your new page… which isn’t empty since Evennia uses our “base.html” template. In
+the content of our page, there’s only a heading that reads “help index”. Notice that the title of
+our page is “mygame - Help index” (“mygame” is replaced by the name of your game).
+
From now on, it will be easier to move forward and add features.
Have the help of commands and help entries accessed online.
+
Have various commands and help entries depending on whether the user is logged in or not.
+
+
In terms of pages, we’ll have:
+
+
One to display the list of help topics.
+
One to display the content of a help topic.
+
+
The first one would link to the second.
+
+
Should we create two URLs?
+
+
The answer is… maybe. It depends on what you want to do. We have our help index accessible
+through the “/help_system/” URL. We could have the detail of a help entry accessible through
+“/help_system/desc” (to see the detail of the “desc” command). The problem is that our commands or
+help topics may contain special characters that aren’t to be present in URLs. There are different
+ways around this problem. I have decided to use a GET variable here, which would create URLs like
+this:
+
/help_system?name=desc
+
+
+
If you use this system, you don’t have to add a new URL: GET and POST variables are accessible
+through our requests and we’ll see how soon enough.
One of our requirements is to have a help system tailored to our accounts. If an account with admin
+access logs in, the page should display a lot of commands that aren’t accessible to common users.
+And perhaps even some additional help topics.
+
Fortunately, it’s fairly easy to get the logged in account in our view (remember that we’ll do most
+of our coding there). The request object, passed to our function, contains a user attribute.
+This attribute will always be there: we cannot test whether it’s None or not, for instance. But
+when the request comes from a user that isn’t logged in, the user attribute will contain an
+anonymous Django user. We then can use the is_anonymous method to see whether the user is logged-
+in or not. Last gift by Evennia, if the user is logged in, request.user contains a reference to
+an account object, which will help us a lot in coupling the game and online system.
In this second case, it will select the first character of the account.
+
But what if the user’s not logged in? Again, we have different solutions. One of the most simple
+is to create a character that will behave as our default character for the help system. You can
+create it through your game: connect to it and enter:
+
@charcreate anonymous
+
+
+
The system should answer:
+
Created new character anonymous. Use @ic anonymous to enter the game as this character.
+
+
+
So in our view, we could have something like this:
What we’re going to do is to browse through all commands and help entries, and list all the commands
+that can be seen by this character (either our ‘anonymous’ character, or our logged-in character).
+
The code is longer, but it presents the entire concept in our view. Edit the
+“web/help_system/views.py” file and paste into it:
+
fromdjango.httpimportHttp404
+fromdjango.shortcutsimportrender
+fromevennia.help.modelsimportHelpEntry
+
+fromtypeclasses.charactersimportCharacter
+
+defindex(request):
+ """The 'index' view."""
+ user=request.user
+ ifnotuser.is_anonymous()anduser.character:
+ character=user.character
+ else:
+ character=Character.objects.get(db_key="anonymous")
+
+ # Get the categories and topics accessible to this character
+ categories,topics=_get_topics(character)
+
+ # If we have the 'name' in our GET variable
+ topic=request.GET.get("name")
+ iftopic:
+ iftopicnotintopics:
+ raiseHttp404("This help topic doesn't exist.")
+
+ topic=topics[topic]
+ context={
+ "character":character,
+ "topic":topic,
+ }
+ returnrender(request,"help_system/detail.html",context)
+ else:
+ context={
+ "character":character,
+ "categories":categories,
+ }
+ returnrender(request,"help_system/index.html",context)
+
+def_get_topics(character):
+ """Return the categories and topics for this character."""
+ cmdset=character.cmdset.all()[0]
+ commands=cmdset.commands
+ entries=[entryforentryinHelpEntry.objects.all()]
+ categories={}
+ topics={}
+
+ # Browse commands
+ forcommandincommands:
+ ifnotcommand.auto_helpornotcommand.access(character):
+ continue
+
+ # Create the template for a command
+ template={
+ "name":command.key,
+ "category":command.help_category,
+ "content":command.get_help(character,cmdset),
+ }
+
+ category=command.help_category
+ ifcategorynotincategories:
+ categories[category]=[]
+ categories[category].append(template)
+ topics[command.key]=template
+
+ # Browse through the help entries
+ forentryinentries:
+ ifnotentry.access(character,'view',default=True):
+ continue
+
+ # Create the template for an entry
+ template={
+ "name":entry.key,
+ "category":entry.help_category,
+ "content":entry.entrytext,
+ }
+
+ category=entry.help_category
+ ifcategorynotincategories:
+ categories[category]=[]
+ categories[category].append(template)
+ topics[entry.key]=template
+
+ # Sort categories
+ forentriesincategories.values():
+ entries.sort(key=lambdac:c["name"])
+
+ categories=list(sorted(categories.items()))
+ returncategories,topics
+
+
+
That’s a bit more complicated here, but all in all, it can be divided in small chunks:
+
+
The index function is our view:
+
+
It begins by getting the character as we saw in the previous section.
+
It gets the help topics (commands and help entries) accessible to this character. It’s another
+function that handles that part.
+
If there’s a GET variable “name” in our URL (like “/help?name=drop”), it will retrieve it. If
+it’s not a valid topic’s name, it returns a 404. Otherwise, it renders the template called
+“detail.html”, to display the detail of our topic.
+
If there’s no GET variable “name”, render “index.html”, to display the list of topics.
+
+
+
The _get_topics is a private function. Its sole mission is to retrieve the commands a character
+can execute, and the help entries this same character can see. This code is more Evennia-specific
+than Django-specific, it will not be detailed in this tutorial. Just notice that all help topics
+are stored in a dictionary. This is to simplify our job when displaying them in our templates.
+
+
Notice that, in both cases when we asked to render a template, we passed to render a third
+argument which is the dictionary of variables used in our templates. We can pass variables this
+way, and we will use them in our templates.
This template is definitely more detailed. What it does is:
+
+
Browse through all categories.
+
For all categories, display a level-2 heading with the name of the category.
+
All topics in a category (remember, they can be either commands or help entries) are displayed in
+a table. The trickier part may be that, when the loop is above 5, it will create a new line. The
+table will have 5 columns at the most per row.
+
For every cell in the table, we create a link redirecting to the detail page (see below). The
+URL would look something like “help?name=say”. We use urlencode to ensure special characters are
+properly escaped.
It’s now time to show the detail of a topic (command or help entry). You can create the file
+“web/help_system/templates/help_system/detail.html”. You can paste into it the following code:
Remember to reload or start Evennia, and then go to
+http://localhost:4001/help_system. You should see the list of
+commands and topics accessible by all characters. Try to login (click the “login” link in the menu
+of your website) and go to the same page again. You should now see a more detailed list of commands
+and help entries. Click on one to see its detail.
As always, a tutorial is here to help you feel comfortable adding new features and code by yourself.
+Here are some ideas of things to improve this little feature:
+
+
Links at the bottom of the detail template to go back to the index might be useful.
+
A link in the main menu to link to this page would be great… for the time being you have to
+enter the URL, users won’t guess it’s there.
+
Colors aren’t handled at this point, which isn’t exactly surprising. You could add it though.
+
Linking help entries between one another won’t be simple, but it would be great. For instance, if
+you see a help entry about how to use several commands, it would be great if these commands were
+themselves links to display their details.
An important part of Evennia is the online help system. This allows the players and staff alike to
+learn how to use the game’s commands as well as other information pertinent to the game. The help
+system has many different aspects, from the normal editing of help entries from inside the game, to
+auto-generated help entries during code development using the auto-help system.
This will show a list of help entries, ordered after categories. You will find two sections,
+Command help entries and Other help entries (initially you will only have the first one). You
+can use help to get more info about an entry; you can also give partial matches to get suggestions.
+If you give category names you will only be shown the topics in that category.
A common item that requires help entries are in-game commands. Keeping these entries up-to-date with
+the actual source code functionality can be a chore. Evennia’s commands are therefore auto-
+documenting straight from the sources through its auto-help system. Only commands that you and
+your character can actually currently use are picked up by the auto-help system. That means an admin
+will see a considerably larger amount of help topics than a normal player when using the default
+help command.
+
The auto-help system uses the __doc__ strings of your command classes and formats this to a nice-
+looking help entry. This makes for a very easy way to keep the help updated - just document your
+commands well and updating the help file is just a @reload away. There is no need to manually
+create and maintain help database entries for commands; as long as you keep the docstrings updated
+your help will be dynamically updated for you as well.
+
Example (from a module with command definitions):
+
classCmdMyCmd(Command):
+ """
+ mycmd - my very own command
+
+ Usage:
+ mycmd[/switches] <args>
+
+ Switches:
+ test - test the command
+ run - do something else
+
+ This is my own command that does this and that.
+
+ """
+ # [...]
+
+ help_category="General"# default
+ auto_help=True# default
+
+ # [...]
+
+
+
The text at the very top of the command class definition is the class’ __doc__-string and will be
+shown to users looking for help. Try to use a consistent format - all default commands are using the
+structure shown above.
+
You should also supply the help_category class property if you can; this helps to group help
+entries together for people to more easily find them. See the help command in-game to see the
+default categories. If you don’t specify the category, “General” is assumed.
+
If you don’t want your command to be picked up by the auto-help system at all (like if you want to
+write its docs manually using the info in the next section or you use a cmdset that
+has its own help functionality) you can explicitly set auto_help class property to False in your
+command definition.
+
Alternatively, you can keep the advantages of auto-help in commands, but control the display of
+command helps. You can do so by overriding the command’s get_help() method. By default, this
+method will return the class docstring. You could modify it to add custom behavior: the text
+returned by this method will be displayed to the character asking for help in this command.
These are all help entries not involving commands (this is handled automatically by the Command
+Auto-help system). Non-automatic help entries describe how
+your particular game is played - its rules, world descriptions and so on.
+
A help entry consists of four parts:
+
+
The topic. This is the name of the help entry. This is what players search for when they are
+looking for help. The topic can contain spaces and also partial matches will be found.
+
The help category. Examples are Administration, Building, Comms or General. This is an
+overall grouping of similar help topics, used by the engine to give a better overview.
+
The text - the help text itself, of any length.
+
locks - a lock definition. This can be used to limit access to this help entry, maybe
+because it’s staff-only or otherwise meant to be restricted. Help commands check for access_types
+view and edit. An example of a lock string would be view:perm(Builders).
+
+
You can create new help entries in code by using evennia.create_help_entry().
+
fromevenniaimportcreate_help_entry
+entry=create_help_entry("emote",
+ "Emoting is important because ...",
+ category="Roleplaying",locks="view:all()")
+
+
+
From inside the game those with the right permissions can use the @sethelp command to add and
+modify help entries.
+
> @sethelp/add emote = The emote command is ...
+
+
+
Using @sethelp you can add, delete and append text to existing entries. By default new entries
+will go in the General help category. You can change this using a different form of the @sethelp
+command:
+
> @sethelp/add emote, Roleplaying = Emoting is important because ...
+
+
+
If the category Roleplaying did not already exist, it is created and will appear in the help
+index.
+
You can, finally, define a lock for the help entry by following the category with a lock
+definition:
+
> @sethelp/add emote, Roleplaying, view:all() = Emoting is ...
+
If you cannot find what you are looking for in the online documentation, here’s what to do:
+
+
If you think the documentation is not clear enough and are short on time, fill in our quick little
+online form and let us know - no login required. Maybe the docs need to be improved or a new
+tutorial added! Note that while this form is useful as a suggestion box we cannot answer questions
+or reply to you. Use the discussion group or chat (linked below) if you want feedback.
+
If you have trouble with a missing feature or a problem you think is a bug, go to the
+issue tracker and search to see if has been reported/suggested already. If you can’t find an
+existing entry create a new one.
+
If you need help, want to start a discussion or get some input on something you are working on,
+make a post to the discussions group This is technically a ‘mailing list’, but you don’t
+need to use e-mail; you can post and read all messages just as easily from your browser via the
+online interface.
+
If you want more direct discussions with developers and other users, consider dropping into our
+IRC chat channel #evennia on the Freenode network. Please note however that you have to be
+patient if you don’t get any response immediately; we are all in very different time zones and many
+have busy personal lives. So you might have to hang around for a while - you’ll get noticed
+eventually!
Evennia is a completely non-funded project. It relies on the time donated by its users and
+developers in order to progress.
+
The first and easiest way you as a user can help us out is by taking part in
+community discussions and by giving feedback on what is good or bad. Report bugs you find and features
+you lack to our issue tracker. Just the simple act of letting developers know you are out
+there using their program is worth a lot. Generally mentioning and reviewing Evennia elsewhere is
+also a nice way to spread the word.
+
If you’d like to help develop Evennia more hands-on, here are some ways to get going:
+
+
Look through our online documentation wiki and see if you
+can help improve or expand the documentation (even small things like fixing typos!). You don’t need
+any particular permissions to edit the wiki.
+
Send a message to our discussion group and/or our IRC chat asking about what
+needs doing, along with what your interests and skills are.
+
Take a look at our issue tracker and see if there’s something you feel like taking on.
+here are bugs that need fixes. At any given time there may also be some
+[bounties][issues-bounties] open - these are issues members of the community has put up money to see
+fixed (if you want to put up a bounty yourself you can do so via our page on
+[bountysource][bountysource]).
+
Check out the Contributing page on how to practically contribute with code using
+github.
+
+
… And finally, if you want to help motivate and support development you can also drop some coins
+in the developer’s cup. You can make a donation via PayPal or, even better,
+[become an Evennia patron on Patreon][patreon]! This is a great way to tip your hat and show that you
+appreciate the work done with the server! Finally, if you want to encourage the community to resolve
+a particular
Twitter is an online social networking service that enables
+users to send and read short 280-character messages called “tweets”. Following is a short tutorial
+explaining how to enable users to send tweets from inside Evennia.
You must first have a Twitter account. Log in and register an App at the Twitter Dev
+Site. Make sure you enable access to “write” tweets!
+
To tweet from Evennia you will need both the “API Token” and the “API secret” strings as well as the
+“Access Token” and “Access Secret” strings.
+
Twitter changed their requirements to require a Mobile number on the Twitter account to register new
+apps with write access. If you’re unable to do this, please see this Dev
+post which describes how to get around
+it.
Evennia doesn’t have a tweet command out of the box so you need to write your own little
+Command in order to tweet. If you are unsure about how commands work and how to add
+them, it can be an idea to go through the Adding a Command Tutorial
+before continuing.
+
You can create the command in a separate command module (something like mygame/commands/tweet.py)
+or together with your other custom commands, as you prefer.
+
This is how it can look:
+
importtwitter
+fromevenniaimportCommand
+
+# here you insert your unique App tokens
+# from the Twitter dev site
+TWITTER_API=twitter.Api(consumer_key='api_key',
+ consumer_secret='api_secret',
+ access_token_key='access_token_key',
+ access_token_secret='access_token_secret')
+
+classCmdTweet(Command):
+ """
+ Tweet a message
+
+ Usage:
+ tweet <message>
+
+ This will send a Twitter tweet to a pre-configured Twitter account.
+ A tweet has a maximum length of 280 characters.
+ """
+
+ key="tweet"
+ locks="cmd:pperm(tweet) or pperm(Developers)"
+ help_category="Comms"
+
+ deffunc(self):
+ "This performs the tweet"
+
+ caller=self.caller
+ tweet=self.args
+
+ ifnottweet:
+ caller.msg("Usage: tweet <message>")
+ return
+
+ tlen=len(tweet)
+ iftlen>280:
+ caller.msg("Your tweet was %i chars long (max 280)."%tlen)
+ return
+
+ # post the tweet
+ TWITTER_API.PostUpdate(tweet)
+
+ caller.msg("You tweeted:\n%s"%tweet)
+
+
+
Be sure to substitute your own actual API/Access keys and secrets in the appropriate places.
+
We default to limiting tweet access to players with Developers-level access or to those players
+that have the permission “tweet” (allow individual characters to tweet with @perm/playerplayername=tweet). You may change the lock as you feel is appropriate. Change the overall
+permission to Players if you want everyone to be able to tweet.
+
Now add this command to your default command set (e.g in mygame/commands/defalt_cmdsets.py”) and
+reload the server. From now on those with access can simply use tweet<message> to see the tweet
+posted from the game’s Twitter account.
This shows only a basic tweet setup, other things to do could be:
+
+
Auto-Adding the character name to the tweet
+
More error-checking of postings
+
Changing locks to make tweeting open to more people
+
Echo your tweets to an in-game channel
+
+
Rather than using an explicit command you can set up a Script to send automatic tweets, for example
+to post updated game stats. See the Tweeting Game Stats tutorial for
+help.
Disambiguation: This page is related to using IRC inside an Evennia game. To join the official
+Evennia IRC chat, connect to irc.freenode.net and join #evennia. Alternatively, you can join our
+Discord, which is mirrored to IRC.
+
IRC (Internet Relay Chat) is a long standing
+chat protocol used by many open-source projects for communicating in real time. By connecting one of
+Evennia’s Channels to an IRC channel you can communicate also with people not on
+an mud themselves. You can also use IRC if you are only running your Evennia MUD locally on your
+computer (your game doesn’t need to be open to the public)! All you need is an internet connection.
+For IRC operation you also need twisted.words.
+This is available simply as a package python-twisted-words in many Linux distros, or directly
+downloadable from the link.
You can connect IRC to any Evennia channel (so you could connect it to the default public channel
+if you like), but for testing, let’s set up a new channel irc.
+
@ccreate irc = This is connected to an irc channel!
+
+
+
You will automatically join the new channel.
+
Next we will create a connection to an external IRC network and channel. There are many, many IRC
+nets. Here is a list of some of the biggest
+ones, the one you choose is not really very important unless you want to connect to a particular
+channel (also make sure that the network allows for “bots” to connect).
+
For testing, we choose the Freenode network, irc.freenode.net. We will connect to a test
+channel, let’s call it #myevennia-test (an IRC channel always begins with #). It’s best if you
+pick an obscure channel name that didn’t exist previously - if it didn’t exist it will be created
+for you.
+
+
Don’t connect to #evennia for testing and debugging, that is Evennia’s official chat channel!
+You are welcome to connect your game to #evennia once you have everything working though - it
+can be a good way to get help and ideas. But if you do, please do so with an in-game channel open
+only to your game admins and developers).
+
+
The port needed depends on the network. For Freenode this is 6667.
+
What will happen is that your Evennia server will connect to this IRC channel as a normal user. This
+“user” (or “bot”) needs a name, which you must also supply. Let’s call it “mud-bot”.
+
To test that the bot connects correctly you also want to log onto this channel with a separate,
+third-party IRC client. There are hundreds of such clients available. If you use Firefox, the
+Chatzilla plugin is good and easy. Freenode also offers its own web-based chat page. Once you
+have connected to a network, the command to join is usually /join#channelname (don’t forget the
+#).
Evennia will now create a new IRC bot mud-bot and connect it to the IRC network and the channel
+#myevennia. If you are connected to the IRC channel you will soon see the user mud-bot connect.
+
Write something in the Evennia channel irc.
+
irc Hello, World!
+[irc] Anna: Hello, World!
+
+
+
If you are viewing your IRC channel with a separate IRC client you should see your text appearing
+there, spoken by the bot:
+
mud-bot> [irc] Anna: Hello, World!
+
+
+
Write Hello! in your IRC client window and it will appear in your normal channel, marked with the
+name of the IRC channel you used (#evennia here).
+
[irc] Anna@#myevennia-test: Hello!
+
+
+
Your Evennia gamers can now chat with users on external IRC channels!
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.
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, 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. Finally you could even go
+with a custom django model. Which is the better depends on your game and the
+complexity of your system.
+
Make a clear API 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:
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.
Inspired by tabletop role playing games, most game systems mimic some sort of die mechanic. To this
+end Evennia offers a full dice
+roller 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.
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
+
+
+
+
+
+
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 plus the combatvalue is greater than
+the other character, 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).
+
Characters with the name dummy will gain no XP. Allowing us to make a dummy to train with.
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.
+
fromrandomimportrandint
+fromevenniaimportDefaultCharacter
+
+classCharacter(DefaultCharacter):
+ """
+ Custom rule-restricted character. We randomize
+ the initial skill and ability values bettween 1-10.
+ """
+ defat_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 examineself 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/resetself
+
+
+
The examineself command will now show the new Attributes.
"""
+mygame/world/rules.py
+"""
+fromrandomimportrandint
+
+
+defroll_hit():
+ "Roll 1d100"
+ returnrandint(1,100)
+
+
+defroll_dmg():
+ "Roll 1d6"
+ returnrandint(1,6)
+
+
+defcheck_defeat(character):
+ "Checks if a character is 'defeated'."
+ ifcharacter.db.HP<=0:
+ character.msg("You fall down, defeated!")
+ character.db.HP=100# reset
+
+
+defadd_XP(character,amount):
+ "Add XP to character, tracking level increases."
+ if"training_dummy"incharacter.tags.all():# don't allow the training dummy to level
+ character.location.msg_contents("Training Dummies can not gain XP.")
+ return
+ else:
+ character.db.XP+=amount
+ ifcharacter.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)
+
+
+defskill_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)
+
+ # display messages showing attack numbers
+ attack_message=f"{char1.name} rolls {roll1} + combat {char1.db.combat} " \
+ f"= {char1.db.combat+roll1} | {char2.name} rolls {roll2} + combat " \
+ f"{char2.db.combat} = {char2.db.combat+roll2}"
+ char1.location.msg_contents(attack_message)
+ attack_summary=f"{char1.name}{char1.db.combat+roll1} " \
+ f"vs {char2.name}{char2.db.combat+roll2}"
+ char1.location.msg_contents(attack_summary)
+
+ ifchar1.db.combat+roll1>char2.db.combat+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)
+ elifchar2.db.combat+roll2>char1.db.combat+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}
+
+
+defroll_challenge(character1,character2,skillname):
+ """
+ Determine the outcome of a skill challenge between
+ two characters based on the skillname given.
+ """
+ ifskillnameinSKILLS:
+ SKILLS[skillname](character1,character2)
+ else:
+ raiseRunTimeError("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.
fromevenniaimportCommand
+fromworldimportrules
+
+classCmdAttack(Command):
+ """
+ attack an opponent
+
+ Usage:
+ attack <target>
+
+ This will attack a target in the same room, dealing
+ damage with your bare hands.
+ """
+ deffunc(self):
+ "Implementing combat"
+
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("You need to pick a target to attack.")
+ return
+
+ target=caller.search(self.args)
+ iftarget:
+ 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.
An inputfunc is an Evennia function that handles a particular input (an inputcommand) from
+the client. The inputfunc is the last destination for the inputcommand along the ingoing message
+path. The inputcommand always has the form (commandname,(args),{kwargs}) and Evennia will use this to try to find and call an inputfunc on the form
This is simple. Add a function on the above form to mygame/server/conf/inputfuncs.py. Your
+function must be in the global, outermost scope of that module and not start with an underscore
+(_) to be recognized as an inputfunc. Reload the server. That’s it. To overload a default
+inputfunc (see below), just add a function with the same name.
+
The modules Evennia looks into for inputfuncs are defined in the list settings.INPUT_FUNC_MODULES.
+This list will be imported from left to right and later imported functions will replace earlier
+ones.
This is the most common of inputcommands, and the only one supported by every traditional mud. The
+argument is usually what the user sent from their command line. Since all text input from the user
+like this is considered a Command, this inputfunc will do things like nick-replacement
+and then pass on the input to the central Commandhandler.
This is a direct command for setting protocol options. These are settable with the @option
+command, but this offers a client-side way to set them. Not all connection protocols makes use of
+all flags, but here are the possible keywords:
+
+
get (bool): If this is true, ignore all other kwargs and immediately return the current settings
+as an outputcommand ("client_options",(),{key=value,...})-
+
client (str): A client identifier, like “mushclient”.
+
version (str): A client version
+
ansi (bool): Supports ansi colors
+
xterm256 (bool): Supports xterm256 colors or not
+
mxp (bool): Supports MXP or not
+
utf-8 (bool): Supports UTF-8 or not
+
screenreader (bool): Screen-reader mode on/off
+
mccp (bool): MCCP compression on/off
+
screenheight (int): Screen height in lines
+
screenwidth (int): Screen width in characters
+
inputdebug (bool): Debug input functions
+
nomarkup (bool): Strip all text tags
+
raw (bool): Leave text tags unparsed
+
+
+
Note that there are two GMCP aliases to this inputfunc - hello and supports_set, which means
+it will be accessed via the GMCP Hello and Supports.Set instructions assumed by some clients.
Returns an outputcommand on the form ("get_inputfuncs",(),{funcname:docstring,...}) - a list of
+all the available inputfunctions along with their docstrings.
Retrieves a value from the Character or Account currently controlled by this Session. Takes one
+argument, This will only accept particular white-listed names, you’ll need to overload the function
+to expand. By default the following values can be retrieved:
+
+
“name” or “key”: The key of the Account or puppeted Character.
+
“location”: Name of the current location, or “None”.
+
“servername”: Name of the Evennia server connected to.
Output: Depends on the repeated function. Will return ("text",(repeatlist),{} with a list of
+accepted names if given an unfamiliar callback name.
+
+
This will tell evennia to repeatedly call a named function at a given interval. Behind the scenes
+this will set up a Ticker. Only previously acceptable functions are possible to
+repeat-call in this way, you’ll need to overload this inputfunc to add the ones you want to offer.
+By default only two example functions are allowed, “test1” and “test2”, which will just echo a text
+back at the given interval. Stop the repeat by sending "stop":True (note that you must include
+both the callback name and interval for Evennia to know what to stop).
Output (on change): ("monitor",(),{"name":name,"value":value})
+
+
This sets up on-object monitoring of Attributes or database fields. Whenever the field or Attribute
+changes in any way, the outputcommand will be sent. This is using the
+MonitorHandler behind the scenes. Pass the “stop” key to stop monitoring. Note
+that you must supply the name also when stopping to let the system know which monitor should be
+cancelled.
+
Only fields/attributes in a whitelist are allowed to be used, you have to overload this function to
+add more. By default the following fields/attributes can be monitored:
This page describes how to install and run the Evennia server on an Android phone. This will involve
+installing a slew of third-party programs from the Google Play store, so make sure you are okay with
+this before starting.
The first thing to do is install a terminal emulator that allows a “full” version of linux to be
+run. Note that Android is essentially running on top of linux so if you have a rooted phone, you may
+be able to skip this step. You don’t require a rooted phone to install Evennia though.
+
Assuming we do not have root, we will install
+Termux.
+Termux provides a base installation of Linux essentials, including apt and Python, and makes them
+available under a writeable directory. It also gives us a terminal where we can enter commands. By
+default, Android doesn’t give you permissions to the root folder, so Termux pretends that its own
+installation directory is the root directory.
+
Termux will set up a base system for us on first launch, but we will need to install some
+prerequisites for Evennia. Commands you should run in Termux will look like this:
+
$ cat file.txt
+
+
+
The $ symbol is your prompt - do not include it when running commands.
To install some of the libraries Evennia requires, namely Pillow and Twisted, we have to first
+install some packages they depend on. In Termux, run the following
Termux ships with Python 3, perfect. Python 3 has venv (virtualenv) and pip (Python’s module
+installer) built-in.
+
So, let’s set up our virtualenv. This keeps the Python packages we install separate from the system
+versions.
+
$ cd
+$ python3 -m venv evenv
+
+
+
This will create a new folder, called evenv, containing the new python executable.
+Next, let’s activate our new virtualenv. Every time you want to work on Evennia, you need to run the
+following command:
+
$ source evenv/bin/activate
+
+
+
Your prompt will change to look like this:
+
(evenv) $
+
+
+
Update the updaters and installers in the venv: pip, setuptools and wheel.
This step will possibly take quite a while - we are downloading Evennia and are then installing it,
+building all of the requirements for Evennia to run. If you run into trouble on this step, please
+see Troubleshooting.
+
You can go to the dir where Evennia is installed with cd$VIRTUAL_ENV/src/evennia. gitgrep(something) can be handy, as can gitdiff
At this point, Evennia is installed on your phone! You can now continue with the original Getting
+Started instruction, we repeat them here for clarity.
Your game should now be running! Open a web browser at http://localhost:4001 or point a telnet
+client to localhost:4000 and log in with the user you created.
When you wish to run Evennia, get into your Termux console and make sure you have activated your
+virtualenv as well as are in your game’s directory. You can then run evennia start as normal.
+
$ cd ~ && source evenv/bin/activate
+(evenv) $ cd mygame
+(evenv) $ evennia start
+
Android’s os module doesn’t support certain functions - in particular getloadavg. Thusly, running
+the command @server in-game will throw an exception. So far, there is no fix for this problem.
+
As you might expect, performance is not amazing.
+
Android is fairly aggressive about memory handling, and you may find that your server process is
+killed if your phone is heavily taxed. Termux seems to keep a notification up to discourage this.
Internationalization (often abbreviated i18n since there are 18 characters between the first “i”
+and the last “n” in that word) allows Evennia’s core server to return texts in other languages than
+English - without anyone having to edit the source code. Take a look at the locale directory of
+the Evennia installation, there you will find which languages are currently supported.
Change language by adding the following to your mygame/server/conf/settings.py file:
+
USE_I18N=True
+ LANGUAGE_CODE='en'
+
+
+
Here 'en' should be changed to the abbreviation for one of the supported languages found in
+locale/. Restart the server to activate i18n. The two-character international language codes are
+found here.
+
+
Windows Note: If you get errors concerning gettext or xgettext on Windows, see the Django
+documentation. A
+self-installing and up-to-date version of gettext for Windows (32/64-bit) is available on
+Github.
Important Note: Evennia offers translations of hard-coded strings in the server, things like
+“Connection closed” or “Server restarted”, strings that end users will see and which game devs are
+not supposed to change on their own. Text you see in the log file or on the command line (like error
+messages) are generally not translated (this is a part of Python).
+
+
+
In addition, text in default Commands and in default Typeclasses will not be translated by
+switching i18n language. To translate Commands and Typeclass hooks you must overload them in your
+game directory and translate their returns to the language you want. This is because from Evennia’s
+perspective, adding i18n code to commands tend to add complexity to code that is meant to be
+changed anyway. One of the goals of Evennia is to keep the user-changeable code as clean and easy-
+to-read as possible.
+
+
If you cannot find your language in evennia/locale/ it’s because noone has translated it yet.
+Alternatively you might have the language but find the translation bad … You are welcome to help
+improve the situation!
+
To start a new translation you need to first have cloned the Evennia repositry with GIT and
+activated a python virtualenv as described on the Getting Started page. You now
+need to cd to the evennia/ directory. This is not your created game folder but the main
+Evennia library folder. If you see a folder locale/ then you are in the right place. From here you
+run:
+
evennia makemessages <language-code>
+
+
+
where <language-code> is the two-letter locale code
+for the language you want, like ‘sv’ for Swedish or ‘es’ for Spanish. After a moment it will tell
+you the language has been processed. For instance:
+
evennia makemessages sv
+
+
+
If you started a new language a new folder for that language will have emerged in the locale/
+folder. Otherwise the system will just have updated the existing translation with eventual new
+strings found in the server. Running this command will not overwrite any existing strings so you can
+run it as much as you want.
+
+
Note: in Django, the makemessages command prefixes the locale name by the -l option (...makemessages-lsv for instance). This syntax is not allowed in Evennia, due to the fact that -l
+is the option to tail log files. Hence, makemessages doesn’t use the -l flag.
+
+
Next head to locale/<language-code>/LC_MESSAGES and edit the **.po file you find there. You can
+edit this with a normal text editor but it is easiest if you use a special po-file editor from the
+web (search the web for “po editor” for many free alternatives).
+
The concept of translating is simple, it’s just a matter of taking the english strings you find in
+the **.po file and add your language’s translation best you can. The **.po format (and many
+supporting editors) allow you to mark translations as “fuzzy”. This tells the system (and future
+translators) that you are unsure about the translation, or that you couldn’t find a translation that
+exactly matched the intention of the original text. Other translators will see this and might be
+able to improve it later.
+Finally, you need to compile your translation into a more efficient form. Do so from the evennia
+folder
+again:
+
evennia compilemessages
+
+
+
This will go through all languages and create/update compiled files (**.mo) for them. This needs
+to be done whenever a **.po file is updated.
+
When you are done, send the **.po and *.mo file to the Evennia developer list (or push it into
+your own repository clone) so we can integrate your translation into Evennia!
Evennia provides a great foundation to build your very own MU* whether you have programming
+experience or none at all. Whilst Evennia has a number of in-game building commands and tutorials
+available to get you started, when approaching game systems of any complexity it is advisable to
+have the basics of Python under your belt before jumping into the code. There are many Python
+tutorials freely available online however this page focuses on Learn Python the Hard Way (LPTHW) by
+Zed Shaw. This tutorial takes you through the basics of Python and progresses you to creating your
+very own online text based game. Whilst completing the course feel free to install Evennia and try
+out some of our beginner tutorials. On completion you can return to this page, which will act as an
+overview to the concepts separating your online text based game and the inner-workings of Evennia.
+-The latter portion of the tutorial focuses working your engine into a webpage and is not strictly
+required for development in Evennia.
You may have returned here when you were invited to read some code. If you haven’t already, you
+should now have the knowledge necessary to install Evennia. Head over to the Getting Started page
+for install instructions. You can also try some of our tutorials to get you started on working with
+Evennia.
If you have successfully completed the Learn Python the Hard Way tutorial you should now have a
+simple browser based Interactive Fiction engine which looks similar to this.
+This engine is built using a single interactive object type, the Room class. The Room class holds a
+description of itself that is presented to the user and a list of hardcoded commands which if
+selected correctly will present you with the next rooms’ description and commands. Whilst your game
+only has one interactive object, MU* have many more: Swords and shields, potions and scrolls or even
+laser guns and robots. Even the player has an in-game representation in the form of your character.
+Each of these examples are represented by their own object with their own description that can be
+presented to the user.
+
A basic object in Evennia has a number of default functions but perhaps most important is the idea
+of location. In your text engine you receive a description of a room but you are not really in the
+room because you have no in-game representation. However, in Evennia when you enter a Dungeon you
+ARE in the dungeon. That is to say your character.location = Dungeon whilst the Dungeon.contents now
+has a spunky young adventurer added to it. In turn, your character.contents may have amongst it a
+number of swords and potions to help you on your adventure and their location would be you.
+
In reality each of these “objects” are just an entry in your Evennia projects database which keeps
+track of all these attributes, such as location and contents. Making changes to those attributes and
+the rules in which they are changed is the most fundamental perspective of how your game works. We
+define those rules in the objects Typeclass. The Typeclass is a Python class with a special
+connection to the games database which changes values for us through various class methods. Let’s
+look at your characters Typeclass rules for changing location.
First we check if it’s okay to leave our current location, then we tell everyone there that we’re
+leaving. We move locations and tell everyone at our new location that we’ve arrived before checking
+we’re okay to be there. By default stages 1 and 5 are empty ready for us to add some rules. We’ll
+leave an explanation as to how to make those changes for the tutorial section, but imagine if you
+were an astronaut. A smart astronaut might stop at step 1 to remember to put his helmet on whilst a
+slower astronaut might realise he’s forgotten in step 5 before shortly after ceasing to be an
+astronaut.
+
With all these objects and all this moving around it raises another problem. In your text engine the
+commands available to the player were hard-coded to the room. That means if we have commands we
+always want available to the player we’ll need to have those commands hard-coded on every single
+room. What about an armoury? When all the swords are gone the command to take a sword would still
+remain causing confusion. Evennia solves this problem by giving each object the ability to hold
+commands. Rooms can have commands attached to them specific to that location, like climbing a tree;
+Players can have commands which are always available to them, like ‘look’, ‘get’ and ‘say’; and
+objects can have commands attached to them which unlock when taking possession of it, like attack
+commands when obtaining a weapon.
Evennia is licensed under the very friendly BSD
+(3-clause) license. You can find the license as
+LICENSE.txt in the Evennia
+repository’s root.
+
Q: When creating a game using Evennia, what does the license permit me to do with it?
+
A: It’s your own game world to do with as you please! Keep it to yourself or re-distribute it
+under another license of your choice - or sell it and become filthy rich for all we care.
+
Q: I have modified the Evennia library itself, what does the license say about that?
+
A: Our license allows you to do whatever you want with your modified Evennia, including
+re-distributing or selling it, as long as you include our license and copyright info found in
+LICENSE.txt along with your distribution.
+
… Of course, if you fix bugs or add some new snazzy feature we softly nudge you to make those
+changes available so they can be added to the core Evennia package for everyone’s benefit. The
+license doesn’t require you to do it, but that doesn’t mean we won’t still greatly appreciate it
+if you do!
+
Q: Can I re-distribute the Evennia server package along with my custom game implementation?
+
A: Sure. As long as the text in LICENSE.txt is included.
+
Q: What about Contributions?
+
The contributions in evennia/evennia/contrib are considered to be released under the same license
+as Evennia itself, unless the individual contributor has specifically defined otherwise.
nextRPI - A github project for making a toolbox for people
+to make RPI-style Evennia games.
+
Muddery - A mud framework under development, based on an
+older fork of Evennia. It has some specific design goals for building and extending the game based
+on input files.
+
vim-evennia - A mode for editing batch-build files (.ev)
+files in the vim text editor (Emacs users can use evennia-
+mode.el).
MuSoapbox - Very active Mu* game community mainly focused on MUSH-
+type gaming.
+
Imaginary Realities - An e-magazine on game and MUD
+design that has several articles about Evennia. There is also an archive of older
+issues from 1998-2001 that are still very
+relevant.
+
Optional Realities - Mud development discussion forums that has
+regular articles on MUD development focused on roleplay-intensive games. After a HD crash it’s not
+as content-rich as it once was.
Planet Mud-Dev - A blog aggregator following blogs of
+current MUD development (including Evennia) around the ‘net. Worth to put among your RSS
+subscriptions.
+
Mud Dev mailing list archive (mirror) -
+Influential mailing list active 1996-2004. Advanced game design discussions.
+
Mud-dev wiki - A (very) slowly growing resource on MUD creation.
Lost Library of MOO - Archive of scientific articles on mudding (in
+particular moo).
+
Nick Gammon’s hints thread -
+Contains a very useful list of things to think about when starting your new MUD.
+
Lost Garden - A game development blog with long and interesting
+articles (not MUD-specific)
+
What Games Are - A blog about general game design (not MUD-specific)
+
The Alexandrian - A blog about tabletop roleplaying and board games,
+but with lots of general discussion about rule systems and game balance that could be applicable
+also for MUDs.
+
Raph Koster’s laws of game design - thought-provoking guidelines and things to think about
+when designing a virtual multiplayer world (Raph is known for Ultima Online among other things).
Richard Bartle Designing Virtual Worlds (amazon page) - Essential reading for the design of any persistent game
+world, written by the co-creator of the original game MUD. Published in 2003 but it’s still as
+relevant now as when it came out. Covers everything you need to know and then some.
+
Zed A. Shaw Learn Python the Hard way (homepage) - Despite
+the imposing name this book is for the absolute Python/programming beginner. One learns the language
+by gradually creating a small text game! It has been used by multiple users before moving on to
+Evennia. Update: This used to be free to read online, this is no longer the case.
+
David M. Beazley Python Essential Reference (4th ed) (amazon
+page) - Our
+recommended book on Python; it not only efficiently summarizes the language but is also an excellent
+reference to the standard library for more experienced Python coders.
+
Luciano Ramalho, Fluent Python (o’reilly
+page) - This is an excellent book for experienced
+Python coders willing to take their code to the next level. A great read with a lot of useful info
+also for veteran Pythonistas.
+
Richard Cantillon An Essay on Economic Theory (free
+pdf) - A very good English
+translation of Essai sur la Nature du Commerce en Général, one of the foundations of modern
+economic theory. Written in 1730 but the translation is annotated and the essay is actually very
+easy to follow also for a modern reader. Required reading if you think of implementing a sane game
+economic system.
For most games it is a good idea to restrict what people can do. In Evennia such restrictions are
+applied and checked by something called locks. All Evennia entities (Commands,
+Objects, Scripts, Accounts, Help System,
+messages and channels) are accessed through locks.
+
A lock can be thought of as an “access rule” restricting a particular use of an Evennia entity.
+Whenever another entity wants that kind of access the lock will analyze that entity in different
+ways to determine if access should be granted or not. Evennia implements a “lockdown” philosophy -
+all entities are inaccessible unless you explicitly define a lock that allows some or full access.
+
Let’s take an example: An object has a lock on itself that restricts how people may “delete” that
+object. Apart from knowing that it restricts deletion, the lock also knows that only players with
+the specific ID of, say, 34 are allowed to delete it. So whenever a player tries to run delete
+on the object, the delete command makes sure to check if this player is really allowed to do so.
+It calls the lock, which in turn checks if the player’s id is 34. Only then will it allow delete
+to go on with its job.
The in-game command for setting locks on objects is lock:
+
> lock obj = <lockstring>
+
+
+
The <lockstring> is a string of a certain form that defines the behaviour of the lock. We will go
+into more detail on how <lockstring> should look in the next section.
+
Code-wise, Evennia handles locks through what is usually called locks on all relevant entities.
+This is a handler that allows you to add, delete and check locks.
+
myobj.locks.add(<lockstring>)
+
+
+
One can call locks.check() to perform a lock check, but to hide the underlying implementation all
+objects also have a convenience function called access. This should preferably be used. In the
+example below, accessing_obj is the object requesting the ‘delete’ access whereas obj is the
+object that might get deleted. This is how it would look (and does look) from inside the delete
+command:
+
ifnotobj.access(accessing_obj,'delete'):
+ accessing_obj.msg("Sorry, you may not delete that.")
+ return
+
Defining a lock (i.e. an access restriction) in Evennia is done by adding simple strings of lock
+definitions to the object’s locks property using obj.locks.add().
+
Here are some examples of lock strings (not including the quotes):
+
delete:id(34)# only allow obj #34 to delete
+ edit:all()# let everyone edit
+ # only those who are not "very_weak" or are Admins may pick this up
+ get:notattr(very_weak)orperm(Admin)
+
where [] marks optional parts. AND, OR and NOT are not case sensitive and excess spaces are
+ignored. lockfunc1,lockfunc2 etc are special lock functions available to the lock system.
+
So, a lockstring consists of the type of restriction (the access_type), a colon (:) and then an
+expression involving function calls that determine what is needed to pass the lock. Each function
+returns either True or False. AND, OR and NOT work as they do normally in Python. If the
+total result is True, the lock is passed.
+
You can create several lock types one after the other by separating them with a semicolon (;) in
+the lockstring. The string below yields the same result as the previous example:
+
delete:id(34);edit:all();get: not attr(very_weak) or perm(Admin)
+
An access_type, the first part of a lockstring, defines what kind of capability a lock controls,
+such as “delete” or “edit”. You may in principle name your access_type anything as long as it is
+unique for the particular object. The name of the access types is not case-sensitive.
+
If you want to make sure the lock is used however, you should pick access_type names that you (or
+the default command set) actually checks for, as in the example of delete above that uses the
+‘delete’ access_type.
+
Below are the access_types checked by the default commandset.
control - who is the “owner” of the object. Can set locks, delete it etc. Defaults to the
+creator of the object.
+
call - who may call Object-commands stored on this Object except for the Object itself. By
+default, Objects share their Commands with anyone in the same location (e.g. so you can ‘press’ a
+Button object in the room). For Characters and Mobs (who likely only use those Commands for
+themselves and don’t want to share them) this should usually be turned off completely, using
+something like call:false().
+
examine - who may examine this object’s properties.
+
delete - who may delete the object.
+
edit - who may edit properties and attributes of the object.
+
view - if the look command will display/list this object
+
get- who may pick up the object and carry it around.
+
puppet - who may “become” this object and control it as their “character”.
+
attrcreate - who may create new attributes on the object (default True)
examine - who may view this help entry (usually everyone)
+
edit - who may edit this help entry.
+
+
+
+
So to take an example, whenever an exit is to be traversed, a lock of the type traverse will be
+checked. Defining a suitable lock type for an exit object would thus involve a lockstring traverse:<lockfunctions>.
As stated above, the access_type part of the lock is simply the ‘name’ or ‘type’ of the lock. The
+text is an arbitrary string that must be unique for an object. If adding a lock with the same
+access_type as one that already exists on the object, the new one override the old one.
+
For example, if you wanted to create a bulletin board system and wanted to restrict who can either
+read a board or post to a board. You could then define locks such as:
This will create a ‘read’ access type for Characters having the Player permission or above and a
+‘post’ access type for those with Admin permissions or above (see below how the perm() lock
+function works). When it comes time to test these permissions, simply check like this (in this
+example, the obj may be a board on the bulletin board system and accessing_obj is the player
+trying to read the board):
+
ifnotobj.access(accessing_obj,'read'):
+ accessing_obj.msg("Sorry, you may not read that.")
+ return
+
A lock function is a normal Python function put in a place Evennia looks for such functions. The
+modules Evennia looks at is the list settings.LOCK_FUNC_MODULES. All functions in any of those
+modules will automatically be considered a valid lock function. The default ones are found in
+evennia/locks/lockfuncs.py and you can start adding your own in mygame/server/conf/lockfuncs.py.
+You can append the setting to add more module paths. To replace a default lock function, just add
+your own with the same name.
+
A lock function must always accept at least two arguments - the accessing object (this is the
+object wanting to get access) and the accessed object (this is the object with the lock). Those
+two are fed automatically as the first two arguments to the function when the lock is checked. Any
+arguments explicitly given in the lock definition will appear as extra arguments.
+
# A simple example lock function. Called with e.g. `id(34)`. This is
+ # defined in, say mygame/server/conf/lockfuncs.py
+
+ defid(accessing_obj,accessed_obj,*args,**kwargs):
+ ifargs:
+ wanted_id=args[0]
+ returnaccessing_obj.id==wanted_id
+ returnFalse
+
+
+
The above could for example be used in a lock function like this:
+
# we have `obj` and `owner_object` from before
+ obj.locks.add("edit: id(%i)"%owner_object.id)
+
+
+
We could check if the “edit” lock is passed with something like this:
+
# as part of a Command's func() method, for example
+ ifnotobj.access(caller,"edit"):
+ caller.msg("You don't have access to edit this!")
+ return
+
+
+
In this example, everyone except the caller with the right id will get the error.
+
+
(Using the * and ** syntax causes Python to magically put all extra arguments into a list
+args and all keyword arguments into a dictionary kwargs respectively. If you are unfamiliar with
+how *args and **kwargs work, see the Python manuals).
+
+
Some useful default lockfuncs (see src/locks/lockfuncs.py for more):
+
+
true()/all() - give access to everyone
+
false()/none()/superuser() - give access to none. Superusers bypass the check entirely and are
+thus the only ones who will pass this check.
+
perm(perm) - this tries to match a given permission property, on an Account firsthand, on a
+Character second. See below.
+
perm_above(perm) - like perm but requires a “higher” permission level than the one given.
+
id(num)/dbref(num) - checks so the access_object has a certain dbref/id.
+
attr(attrname) - checks if a certain Attribute exists on accessing_object.
+
attr(attrname,value) - checks so an attribute exists on accessing_object and has the given
+value.
+
attr_gt(attrname,value) - checks so accessing_object has a value larger (>) than the given
+value.
+
attr_ge,attr_lt,attr_le,attr_ne - corresponding for >=, <, <= and !=.
+
holds(objid) - checks so the accessing objects contains an object of given name or dbref.
+
inside() - checks so the accessing object is inside the accessed object (the inverse of
+holds()).
+
pperm(perm), pid(num)/pdbref(num) - same as perm, id/dbref but always looks for
+permissions and dbrefs of Accounts, not on Characters.
+
serversetting(settingname,value) - Only returns True if Evennia has a given setting or a
+setting set to a given value.
Sometimes you don’t really need to look up a certain lock, you just want to check a lockstring. A
+common use is inside Commands, in order to check if a user has a certain permission. The lockhandler
+has a method check_lockstring(accessing_obj,lockstring,bypass_superuser=False) that allows this.
+
# inside command definition
+ ifnotself.caller.locks.check_lockstring(self.caller,"dummy:perm(Admin)"):
+ self.caller.msg("You must be an Admin or higher to do this!")
+ return
+
+
+
Note here that the access_type can be left to a dummy value since this method does not actually do
+a Lock lookup.
Evennia sets up a few basic locks on all new objects and accounts (if we didn’t, noone would have
+any access to anything from the start). This is all defined in the root Typeclasses
+of the respective entity, in the hook method basetype_setup() (which you usually don’t want to
+edit unless you want to change how basic stuff like rooms and exits store their internal variables).
+This is called once, before at_object_creation, so just put them in the latter method on your
+child object to change the default. Also creation commands like create changes the locks of
+objects you create - for example it sets the control lock_type so as to allow you, its creator, to
+control and delete the object.
This section covers the underlying code use of permissions. If you just want to learn how to
+practically assign permissions in-game, refer to the Building Permissions
+page, which details how you use the perm command.
+
+
A permission is simply a list of text strings stored in the handler permissions on Objects
+and Accounts. Permissions can be used as a convenient way to structure access levels and
+hierarchies. It is set by the perm command. Permissions are especially handled by the perm() and
+pperm() lock functions listed above.
+
Let’s say we have a red_key object. We also have red chests that we want to unlock with this key.
+
perm red_key = unlocks_red_chests
+
+
+
This gives the red_key object the permission “unlocks_red_chests”. Next we lock our red chests:
+
lock red chest = unlock:perm(unlocks_red_chests)
+
+
+
What this lock will expect is to the fed the actual key object. The perm() lock function will
+check the permissions set on the key and only return true if the permission is the one given.
+
Finally we need to actually check this lock somehow. Let’s say the chest has an command open<key>
+sitting on itself. Somewhere in its code the command needs to figure out which key you are using and
+test if this key has the correct permission:
+
# self.obj is the chest
+ # and used_key is the key we used as argument to
+ # the command. The self.caller is the one trying
+ # to unlock the chest
+ ifnotself.obj.access(used_key,"unlock"):
+ self.caller.msg("The key does not fit!")
+ return
+
+
+
All new accounts are given a default set of permissions defined by
+settings.PERMISSION_ACCOUNT_DEFAULT.
+
Selected permission strings can be organized in a permission hierarchy by editing the tuple
+settings.PERMISSION_HIERARCHY. Evennia’s default permission hierarchy is as follows:
+
Developer # like superuser but affected by locks
+ Admin # can administrate accounts
+ Builder # can edit the world
+ Helper # can edit help files
+ Player # can chat and send tells (default level)
+
+
+
(Also the plural form works, so you could use Developers etc too).
+
+
There is also a Guest level below Player that is only active if settings.GUEST_ENABLED is
+set. This is never part of settings.PERMISSION_HIERARCHY.
+
+
The main use of this is that if you use the lock function perm() mentioned above, a lock check for
+a particular permission in the hierarchy will also grant access to those with higher hierarchy
+access. So if you have the permission “Admin” you will also pass a lock defined as perm(Builder)
+or any of those levels below “Admin”.
+
When doing an access check from an Object or Character, the perm() lock function will
+always first use the permissions of any Account connected to that Object before checking for
+permissions on the Object. In the case of hierarchical permissions (Admins, Builders etc), the
+Account permission will always be used (this stops an Account from escalating their permission by
+puppeting a high-level Character). If the permission looked for is not in the hierarchy, an exact
+match is required, first on the Account and if not found there (or if no Account is connected), then
+on the Object itself.
+
Here is how you use perm to give an account more permissions:
+
perm/account Tommy = Builders
+ perm/account/del Tommy = Builders # remove it again
+
+
+
Note the use of the /account switch. It means you assign the permission to the
+Accounts Tommy instead of any Character that also happens to be named
+“Tommy”.
+
Putting permissions on the Account guarantees that they are kept, regardless of which Character
+they are currently puppeting. This is especially important to remember when assigning permissions
+from the hierarchy tree - as mentioned above, an Account’s permissions will overrule that of its
+character. So to be sure to avoid confusion you should generally put hierarchy permissions on the
+Account, not on their Characters (but see also quelling).
+
Below is an example of an object without any connected account
+
obj1.permissions=["Builders","cool_guy"]
+ obj2.locks.add("enter:perm_above(Accounts) and perm(cool_guy)")
+
+ obj2.access(obj1,"enter")# this returns True!
+
+
+
And one example of a puppet with a connected account:
+
account.permissions.add("Accounts")
+ puppet.permissions.add("Builders","cool_guy")
+ obj2.locks.add("enter:perm_above(Accounts) and perm(cool_guy)")
+
+ obj2.access(puppet,"enter")# this returns False!
+
There is normally only one superuser account and that is the one first created when starting
+Evennia (User #1). This is sometimes known as the “Owner” or “God” user. A superuser has more than
+full access - it completely bypasses all locks so no checks are even run. This allows for the
+superuser to always have access to everything in an emergency. But it also hides any eventual errors
+you might have made in your lock definitions. So when trying out game systems you should either use
+quelling (see below) or make a second Developer-level character so your locks get tested correctly.
The quell command can be used to enforce the perm() lockfunc to ignore permissions on the
+Account and instead use the permissions on the Character only. This can be used e.g. by staff to
+test out things with a lower permission level. Return to the normal operation with unquell. Note
+that quelling will use the smallest of any hierarchical permission on the Account or Character, so
+one cannot escalate one’s Account permission by quelling to a high-permission Character. Also the
+superuser can quell their powers this way, making them affectable by locks.
examine: attr(eyesight, excellent) or perm(Builders)
+
+
+
You are only allowed to do examine on this object if you have ‘excellent’ eyesight (that is, has
+an Attribute eyesight with the value excellent defined on yourself) or if you have the
+“Builders” permission string assigned to you.
+
open: holds('the green key') or perm(Builder)
+
+
+
This could be called by the open command on a “door” object. The check is passed if you are a
+Builder or has the right key in your inventory.
+
cmd: perm(Builders)
+
+
+
Evennia’s command handler looks for a lock of type cmd to determine if a user is allowed to even
+call upon a particular command or not. When you define a command, this is the kind of lock you must
+set. See the default command set for lots of examples. If a character/account don’t pass the cmd
+lock type the command will not even appear in their help list.
+
cmd: not perm(no_tell)
+
+
+
“Permissions” can also be used to block users or implement highly specific bans. The above example
+would be be added as a lock string to the tell command. This will allow everyone not having the
+“permission” no_tell to use the tell command. You could easily give an account the “permission”
+no_tell to disable their use of this particular command henceforth.
+
dbref=caller.id
+ lockstring="control:id(%s);examine:perm(Builders);delete:id(%s) or perm(Admin);get:all()"%
+(dbref,dbref)
+ new_obj.locks.add(lockstring)
+
+
+
This is how the create command sets up new objects. In sequence, this permission string sets the
+owner of this object be the creator (the one running create). Builders may examine the object
+whereas only Admins and the creator may delete it. Everyone can pick it up.
Assume we have two objects - one is ourselves (not superuser) and the other is an Object
+called box.
+
> create/drop box
+ > desc box = "This is a very big and heavy box."
+
+
+
We want to limit which objects can pick up this heavy box. Let’s say that to do that we require the
+would-be lifter to to have an attribute strength on themselves, with a value greater than 50. We
+assign it to ourselves to begin with.
+
> set self/strength = 45
+
+
+
Ok, so for testing we made ourselves strong, but not strong enough. Now we need to look at what
+happens when someone tries to pick up the the box - they use the get command (in the default set).
+This is defined in evennia/commands/default/general.py. In its code we find this snippet:
So the get command looks for a lock with the type get (not so surprising). It also looks for an
+Attribute on the checked object called get_err_msg in order to return a customized
+error message. Sounds good! Let’s start by setting that on the box:
+
> set box/get_err_msg = You are not strong enough to lift this box.
+
+
+
Next we need to craft a Lock of type get on our box. We want it to only be passed if the accessing
+object has the attribute strength of the right value. For this we would need to create a lock
+function that checks if attributes have a value greater than a given value. Luckily there is already
+such a one included in evennia (see evennia/locks/lockfuncs.py), called attr_gt.
+
So the lock string will look like this: get:attr_gt(strength,50). We put this on the box now:
+
lock box = get:attr_gt(strength, 50)
+
+
+
Try to get the object and you should get the message that we are not strong enough. Increase your
+strength above 50 however and you’ll pick it up no problem. Done! A very heavy box!
+
If you wanted to set this up in python code, it would look something like this:
+
+ fromevenniaimportcreate_object
+
+ # create, then set the lock
+ box=create_object(None,key="box")
+ box.locks.add("get:attr_gt(strength, 50)")
+
+ # or we can assign locks in one go right away
+ box=create_object(None,key="box",locks="get:attr_gt(strength, 50)")
+
+ # set the attributes
+ box.db.desc="This is a very big and heavy box."
+ box.db.get_err_msg="You are not strong enough to lift this box."
+
+ # one heavy box, ready to withstand all but the strongest...
+
Django also implements a comprehensive permission/security system of its own. The reason we don’t
+use that is because it is app-centric (app in the Django sense). Its permission strings are of the
+form appname.permstring and it automatically adds three of them for each database model in the app
+
+
for the app evennia/object this would be for example ‘object.create’, ‘object.admin’ and
+‘object.edit’. This makes a lot of sense for a web application, not so much for a MUD, especially
+when we try to hide away as much of the underlying architecture as possible.
+
+
The django permissions are not completely gone however. We use it for validating passwords during
+login. It is also used exclusively for managing Evennia’s web-based admin site, which is a graphical
+front-end for the database of Evennia. You edit and assign such permissions directly from the web
+interface. It’s stand-alone from the permissions described above.
This is a small tutorial for customizing your character objects, using the example of letting users
+turn on and off ANSI color parsing as an example. @optionsNOCOLOR=True will now do what this
+tutorial shows, but the tutorial subject can be applied to other toggles you may want, as well.
+
In the Building guide’s Colors page you can learn how to add color to your
+game by using special markup. Colors enhance the gaming experience, but not all users want color.
+Examples would be users working from clients that don’t support color, or people with various seeing
+disabilities that rely on screen readers to play your game. Also, whereas Evennia normally
+automatically detects if a client supports color, it may get it wrong. Being able to turn it on
+manually if you know it should work could be a nice feature.
+
So here’s how to allow those users to remove color. It basically means you implementing a simple
+configuration system for your characters. This is the basic sequence:
+
+
Define your own default character typeclass, inheriting from Evennia’s default.
+
Set an attribute on the character to control markup on/off.
+
Set your custom character class to be the default for new accounts.
+
Overload the msg() method on the typeclass and change how it uses markup.
+
Create a custom command to allow users to change their setting.
Create a new module in mygame/typeclasses named, for example, mycharacter.py. Alternatively you
+can simply add a new class to ‘mygamegame/typeclasses/characters.py’.
+
In your new module(or characters.py), create a new Typeclass inheriting from
+evennia.DefaultCharacter. We will also import evennia.utils.ansi, which we will use later.
+
fromevenniaimportCharacter
+ fromevennia.utilsimportansi
+
+ classColorableCharacter(Character):
+ at_object_creation(self):
+ # set a color config value
+ self.db.config_color=True
+
+
+
Above we set a simple config value as an Attribute.
+
Let’s make sure that new characters are created of this type. Edit your
+mygame/server/conf/settings.py file and add/change BASE_CHARACTER_TYPECLASS to point to your new
+character class. Observe that this will only affect new characters, not those already created. You
+have to convert already created characters to the new typeclass by using the @typeclass command
+(try on a secondary character first though, to test that everything works - you don’t want to render
+your root user unusable!).
+
@typeclass/reset/force Bob = mycharacter.ColorableCharacter
+
+
+
@typeclass changes Bob’s typeclass and runs all its creation hooks all over again. The /reset
+switch clears all attributes and properties back to the default for the new typeclass - this is
+useful in this case to avoid ending up with an object having a “mixture” of properties from the old
+typeclass and the new one. /force might be needed if you edit the typeclass and want to update the
+object despite the actual typeclass name not having changed.
Next we need to overload the msg() method. What we want is to check the configuration value before
+calling the main function. The original msg method call is seen in evennia/objects/objects.py
+and is called like this:
As long as we define a method on our custom object with the same name and keep the same number of
+arguments/keywords we will overload the original. Here’s how it could look:
+
classColorableCharacter(Character):
+ # [...]
+ msg(self,text=None,from_obj=None,session=None,options=None,
+ **kwargs):
+ "our custom msg()"
+ ifself.db.config_colorisnotNone:# this would mean it was not set
+ ifnotself.db.config_color:
+ # remove the ANSI from the text
+ text=ansi.strip_ansi(text)
+ super().msg(text=text,from_obj=from_obj,
+ session=session,**kwargs)
+
+
+
Above we create a custom version of the msg() method. If the configuration Attribute is set, it
+strips the ANSI from the text it is about to send, and then calls the parent msg() as usual. You
+need to @reload before your changes become visible.
+
There we go! Just flip the attribute config_color to False and your users will not see any color.
+As superuser (assuming you use the Typeclass ColorableCharacter) you can test this with the @py
+command:
For completeness, let’s add a custom command so users can turn off their color display themselves if
+they want.
+
In mygame/commands, create a new file, call it for example configcmds.py (it’s likely that
+you’ll want to add other commands for configuration down the line). You can also copy/rename the
+command template.
+
fromevenniaimportCommand
+
+ classCmdConfigColor(Command):
+ """
+ Configures your color
+
+ Usage:
+ @togglecolor on|off
+
+ This turns ANSI-colors on/off.
+ Default is on.
+ """
+
+ key="@togglecolor"
+ aliases=["@setcolor"]
+
+ deffunc(self):
+ "implements the command"
+ # first we must remove whitespace from the argument
+ self.args=self.args.strip()
+ ifnotself.argsornotself.argsin("on","off"):
+ self.caller.msg("Usage: @setcolor on|off")
+ return
+ ifself.args=="on":
+ self.caller.db.config_color=True
+ # send a message with a tiny bit of formatting, just for fun
+ self.caller.msg("Color was turned |won|W.")
+ else:
+ self.caller.db.config_color=False
+ self.caller.msg("Color was turned off.")
+
+
+
Lastly, we make this command available to the user by adding it to the default CharacterCmdSet in
+mygame/commands/default_cmdsets.py and reloading the server. Make sure you also import the
+command:
+
frommygame.commandsimportconfigcmds
+classCharacterCmdSet(default_cmds.CharacterCmdSet):
+ # [...]
+ defat_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ super().at_cmdset_creation()
+ #
+ # any commands you add below will overload the default ones.
+ #
+
+ # here is the only line that we edit
+ self.add(configcmds.CmdConfigColor())
+
Apart from ANSI colors, Evennia also supports Xterm256 colors (See [Colors](./TextTags.md#colored-
+text)). The msg() method supports the xterm256 keyword for manually activating/deactiving
+xterm256. It should be easy to expand the above example to allow players to customize xterm256
+regardless of if Evennia thinks their client supports it or not.
+
To get a better understanding of how msg() works with keywords, you can try this as superuser:
+
@py self.msg("|123Dark blue with xterm256, bright blue with ANSI", xterm256=True)
+@py self.msg("|gThis should be uncolored", nomarkup=True)
+
An easy addition to add dynamic variety to your world objects is to give them some mass. Why mass
+and not weight? Weight varies in setting; for example things on the Moon weigh 1/6 as much. On
+Earth’s surface and in most environments, no relative weight factor is needed.
+
In most settings, mass can be used as weight to spring a pressure plate trap or a floor giving way,
+determine a character’s burden weight for travel speed… The total mass of an object can
+contribute to the force of a weapon swing, or a speeding meteor to give it a potential striking
+force.
Now that we have reasons for keeping track of object mass, let’s look at the default object class
+inside your mygame/typeclasses/objects.py and see how easy it is to total up mass from an object and
+its contents.
+
# inside your mygame/typeclasses/objects.py
+
+classObject(DefaultObject):
+# [...]
+ defget_mass(self):
+ mass=self.attributes.get('mass',1)# Default objects have 1 unit mass.
+ returnmass+sum(obj.get_mass()forobjinself.contents)
+
+
+
Adding the get_mass definition to the objects you want to sum up the masses for is done with
+Python’s “sum” function which operates on all the contents, in this case by summing them to
+return a total mass value.
+
If you only wanted specific object types to have mass or have the new object type in a different
+module, see [[Adding-Object-Typeclass-Tutorial]] with its Heavy class object. You could set the
+default for Heavy types to something much larger than 1 gram or whatever unit you want to use. Any
+non-default mass would be stored on the mass [[Attributes]] of the objects.
You can add a get_mass definition to characters and rooms, also.
+
If you were in a one metric-ton elevator with four other friends also wearing armor and carrying
+gold bricks, you might wonder if this elevator’s going to move, and how fast.
+
Assuming the unit is grams and the elevator itself weights 1,000 kilograms, it would already be
+@setelevator/mass=1000000, we’re @setme/mass=85000 and our armor is @setarmor/mass=50000.
+We’re each carrying 20 gold bars each @setgoldbar/mass=12400 then step into the elevator and see
+the following message in the elevator’s appearance: Elevatorweightandcontentsshouldnotexceed3metrictons. Are we safe? Maybe not if you consider dynamic loading. But at rest:
+
# Elevator object knows when it checks itself:
+ifself.get_mass()<3000000:
+ pass# Elevator functions as normal.
+else:
+ pass# Danger! Alarm sounds, cable snaps, elevator stops...
+
The main functionality of Evennia is to communicate with clients connected to it; a player enters
+commands or their client queries for a gui update (ingoing data). The server responds or sends data
+on its own as the game changes (outgoing data). It’s important to understand how this flow of
+information works in Evennia.
When first connecting, the client can send data to the server about its
+capabilities. This is things like “I support xterm256 but not unicode” and is
+mainly used when a Telnet client connects. This is called a “handshake” and
+will generally set some flags on the Portal Session that
+are later synced to the Server Session. Since this is not something the player
+controls, we’ll not explore this further here.
+
The client can send an inputcommand to the server. Traditionally this only
+happens when the player enters text on the command line. But with a custom
+client GUI, a command could also come from the pressing of a button. Finally
+the client may send commands based on a timer or some trigger.
+
+
Exactly how the inputcommand looks when it travels from the client to Evennia
+depends on the Protocol used:
+
+
Telnet: A string. If GMCP or MSDP OOB protocols are used, this string will
+be formatted in a special way, but it’s still a raw string. If Telnet SSL is
+active, the string will be encrypted.
Each client is connected to the game via a Portal Session, one per connection. This Session is
+different depending on the type of connection (telnet, webclient etc) and thus know how to handle
+that particular data type. So regardless of how the data arrives, the Session will identify the type
+of the instruction and any arguments it should have. For example, the telnet protocol will figure
+that anything arriving normally over the wire should be passed on as a “text” type.
The PortalSessionhandler manages all connected Sessions in the Portal. Its data_in method
+(called by each Portal Session) will parse the command names and arguments from the protocols and
+convert them to a standardized form we call the inputcommand:
+
(commandname,(args),{kwargs})
+
+
+
All inputcommands must have a name, but they may or may not have arguments and keyword arguments -
+in fact no default inputcommands use kwargs at all. The most common inputcommand is “text”, which
+has the argument the player input on the command line:
+
("text",("look",),{})
+
+
+
This inputcommand-structure is pickled together with the unique session-id of the Session to which
+it belongs. This is then sent over the AMP connection.
On the Server side, the AMP unpickles the data and associates the session id with the server-side
+Session. Data and Session are passed to the server-side SessionHandler.data_in. This
+in turn calls ServerSession.data_in()
The method ServerSession.data_in is meant to offer a single place to override if they want to
+examine all data passing into the server from the client. It is meant to call the
+ssessionhandler.call_inputfuncs with the (potentially processed) data (so this is technically a
+sort of detour back to the sessionhandler).
+
In call_inputfuncs, the inputcommand’s name is compared against the names of all the inputfuncs
+registered with the server. The inputfuncs are named the same as the inputcommand they are supposed
+to handle, so the (default) inputfunc for handling our “look” command is called “text”. These are
+just normal functions and one can plugin new ones by simply putting them in a module where Evennia
+looks for such functions.
+
If a matching inputfunc is found, it will be called with the Session and the inputcommand’s
+arguments:
+
text(session,*("look",),**{})
+
+
+
If no matching inputfunc is found, an inputfunc named “default” will be tried and if that is also
+not found, an error will be raised.
The Inputfunc must be on the form func(session,*args,**kwargs). An exception is
+the default inputfunc which has form default(session,cmdname,*args,**kwargs), where cmdname
+is the un-matched inputcommand string.
+
This is where the message’s path diverges, since just what happens next depends on the type of
+inputfunc was triggered. In the example of sending “look”, the inputfunc is named “text”. It will
+pass the argument to the cmdhandler which will eventually lead to the look command being
+executed.
For our purposes, what is important to know is that with the exception of from_obj, session and
+options, all keywords given to the msg method is the name of an outputcommand and its
+arguments. So text is actually such a command, taking a string as its argument. The reason text
+sits as the first keyword argument is that it’s so commonly used (caller.msg("Text") for example).
+Here are some examples
+
msg("Hello!")# using the 'text' outputfunc
+ msg(prompt="HP:%i, SP: %i, MP: %i"%(HP,SP,MP))
+ msg(mycommand=((1,2,3,4),{"foo":"bar"})
+
+
+
+
Note the form of the mycommand outputfunction. This explicitly defines the arguments and keyword
+arguments for the function. In the case of the text and prompt calls we just specify a string -
+this works too: The system will convert this into a single argument for us later in the message
+path.
+
+
Note: The msg method sits on your Object- and Account typeclasses. It means you can easily
+override msg and make custom- or per-object modifications to the flow of data as it passes
+through.
In the ServerSessionhandler, the keywords from the msg method are collated into one or more
+outputcommands on a standardized form (identical to inputcommands):
+
(commandname,(args),{kwargs})
+
+
+
This will intelligently convert different input to the same form. So msg("Hello") will end up as
+an outputcommand ("text",("Hello",),{}).
+
This is also the point where Inlinefuncs are parsed, depending on the
+session to receive the data. Said data is pickled together with the Session id then sent over the
+AMP bridge.
After the AMP connection has unpickled the data and paired the session id to the matching
+PortalSession, the handler next determines if this Session has a suitable method for handling the
+outputcommand.
+
The situation is analogous to how inputfuncs work, except that protocols are fixed things that don’t
+need a plugin infrastructure like the inputfuncs are handled. So instead of an “outputfunc”, the
+handler looks for methods on the PortalSession with names of the form send_<commandname>.
+
For example, the common sending of text expects a PortalSession method send_text. This will be
+called as send_text(*("Hello",),**{}). If the “prompt” outputfunction was used, send_prompt is
+called. In all other cases the send_default(cmdname,*args,**kwargs) will be called - this is the
+case for all client-custom outputcommands, like when wanting to tell the client to update a graphic
+or play a sound.
At this point it is up to the session to convert the command into a form understood by this
+particular protocol. For telnet, send_text will just send the argument as a string (since that is
+what telnet clients expect when “text” is coming). If send_default was called (basically
+everything that is not traditional text or a prompt), it will pack the data as an GMCP or MSDP
+command packet if the telnet client supports either (otherwise it won’t send at all). If sending to
+the webclient, the data will get packed into a JSON structure at all times.
Once arrived at the client, the outputcommand is handled in the way supported by the client (or it
+may be quietly ignored if not). “text” commands will be displayed in the main window while others
+may trigger changes in the GUI or play a sound etc.
For a full outgoing message, you need to have the outgoing function defined in the javascript. See
+https://evennia.readthedocs.io/en/latest/Web-Client-Webclient.html for getting set up with custom
+webclient code. Once you have a custom plugin defined and loaded, create a new function in the
+plugin, onCustomFunc() for example:
You’ll also need to add the function to what the main plugin function returns:
+
return{
+ init:init,
+ onCustomFunc,
+ }
+
+
+
This defines the function and looks for “var” as a variable that is passed to it. Once you have this
+in place in your custom plugin, you also need to update the static/webclient/js/webclient_gui.js
+file to recognize the new function when it’s called. First you should add a new function inside the
+plugin_handler function to recognize the new function:
This looks through all the plugins for a function that corresponds to the custom function being
+called. Next, add the custom function to the return statement of the plugin handler:
Lastly, you will also need to need to add an entry to the Evennia emitter to tie the python function
+call to this new javascript function (this is in the $(document).ready function):
Now you can make a call from your python code to the new custom function to pass information from
+the server to the client:
+
character.msg(customFunc=({"var":"blarg"}))
+
+
+
When this code in your python is run, you should be able to see the “blarg” string printed in the
+web client console. You should now be able to update the function call and definition to pass any
+information needed between server and client.
The MonitorHandler is a system for watching changes in properties or Attributes on objects. A
+monitor can be thought of as a sort of trigger that responds to change.
+
The main use for the MonitorHandler is to report changes to the client; for example the client
+Session may ask Evennia to monitor the value of the Characer’s health attribute and report
+whenever it changes. This way the client could for example update its health bar graphic as needed.
obj (Typeclassed entity) - the object to monitor. Since this must be
+typeclassed, it means you can’t monitor changes on Sessions with the monitorhandler, for
+example.
+
fieldname (str) - the name of a field or Attribute on obj. If you want to
+monitor a database field you must specify its full name, including the starting db_ (like
+db_key, db_location etc). Any names not starting with db_ are instead assumed to be the names
+of Attributes. This difference matters, since the MonitorHandler will automatically know to watch
+the db_value field of the Attribute.
+
callback(callable) - This will be called as callback(fieldname=fieldname,obj=obj,**kwargs)
+when the field updates.
+
idstring (str) - this is used to separate multiple monitors on the same object and fieldname.
+This is required in order to properly identify and remove the monitor later. It’s also used for
+saving it.
+
persistent (bool) - if True, the monitor will survive a server reboot.
+
+
Example:
+
fromevenniaimportMONITOR_HANDLERasmonitorhandler
+
+def_monitor_callback(fieldname="",obj=None,**kwargs):
+ # reporting callback that works both
+ # for db-fields and Attributes
+ iffieldname.startswith("db_"):
+ new_value=getattr(obj,fieldname)
+ else:# an attribute
+ new_value=obj.attributes.get(fieldname)
+
+ obj.msg("%s.%s changed to '%s'."% \
+ (obj.key,fieldname,new_value))
+
+# (we could add _some_other_monitor_callback here too)
+
+# monitor Attribute (assume we have obj from before)
+monitorhandler.add(obj,"desc",_monitor_callback)
+
+# monitor same db-field with two different callbacks (must separate by id_string)
+monitorhandler.add(obj,"db_key",_monitor_callback,id_string="foo")
+monitorhandler.add(obj,"db_key",_some_other_monitor_callback,id_string="bar")
+
+
+
+
A monitor is uniquely identified by the combination of the object instance it is monitoring, the
+name of the field/attribute to monitor on that object and its idstring (obj + fieldname +
+idstring). The idstring will be the empty string unless given explicitly.
+
So to “un-monitor” the above you need to supply enough information for the system to uniquely find
+the monitor to remove:
This tutorial will describe how to make an NPC-run shop. We will make use of the EvMenu
+system to present shoppers with a menu where they can buy things from the store’s stock.
+
Our shop extends over two rooms - a “front” room open to the shop’s customers and a locked “store
+room” holding the wares the shop should be able to sell. We aim for the following features:
+
+
The front room should have an Attribute storeroom that points to the store room.
+
Inside the front room, the customer should have a command buy or browse. This will open a
+menu listing all items available to buy from the store room.
+
A customer should be able to look at individual items before buying.
+
We use “gold” as an example currency. To determine cost, the system will look for an Attribute
+gold_value on the items in the store room. If not found, a fixed base value of 1 will be assumed.
+The wealth of the customer should be set as an Attribute gold on the Character. If not set, they
+have no gold and can’t buy anything.
+
When the customer makes a purchase, the system will check the gold_value of the goods and
+compare it to the gold Attribute of the customer. If enough gold is available, this will be
+deducted and the goods transferred from the store room to the inventory of the customer.
+
We will lock the store room so that only people with the right key can get in there.
We want to show a menu to the customer where they can list, examine and buy items in the store. This
+menu should change depending on what is currently for sale. Evennia’s EvMenu utility will manage
+the menu for us. It’s a good idea to read up on EvMenu if you are not familiar with it.
The shopping menu’s design is straightforward. First we want the main screen. You get this when you
+enter a shop and use the browse or buy command:
+
*** Welcome to ye Old Sword shop! ***
+ Things for sale (choose 1-3 to inspect, quit to exit):
+_________________________________________________________
+1. A rusty sword (5 gold)
+2. A sword with a leather handle (10 gold)
+3. Excalibur (100 gold)
+
+
+
There are only three items to buy in this example but the menu should expand to however many items
+are needed. When you make a selection you will get a new screen showing the options for that
+particular item:
EvMenu defines the nodes (each menu screen with options) as normal Python functions. Each node
+must be able to change on the fly depending on what items are currently for sale. EvMenu will
+automatically make the quit command available to us so we won’t add that manually. For compactness
+we will put everything needed for our shop in one module, mygame/typeclasses/npcshop.py.
+
# mygame/typeclasses/npcshop.py
+
+fromevennia.utilsimportevmenu
+
+defmenunode_shopfront(caller):
+ "This is the top-menu screen."
+
+ shopname=caller.location.key
+ wares=caller.location.db.storeroom.contents
+
+ # Wares includes all items inside the storeroom, including the
+ # door! Let's remove that from our for sale list.
+ wares=[wareforwareinwaresifware.key.lower()!="door"]
+
+ text="*** Welcome to %s! ***\n"%shopname
+ ifwares:
+ text+=" Things for sale (choose 1-%i to inspect);" \
+ " quit to exit:"%len(wares)
+ else:
+ text+=" There is nothing for sale; quit to exit."
+
+ options=[]
+ forwareinwares:
+ # add an option for every ware in store
+ options.append({"desc":"%s (%s gold)"%
+ (ware.key,ware.db.gold_valueor1),
+ "goto":"menunode_inspect_and_buy"})
+ returntext,options
+
+
+
In this code we assume the caller to be inside the shop when accessing the menu. This means we can
+access the shop room via caller.location and get its key to display as the shop’s name. We also
+assume the shop has an Attribute storeroom we can use to get to our stock. We loop over our goods
+to build up the menu’s options.
+
Note that all options point to the same menu node called menunode_inspect_and_buy! We can’t know
+which goods will be available to sale so we rely on this node to modify itself depending on the
+circumstances. Let’s create it now.
+
# further down in mygame/typeclasses/npcshop.py
+
+defmenunode_inspect_and_buy(caller,raw_string):
+ "Sets up the buy menu screen."
+
+ wares=caller.location.db.storeroom.contents
+ # Don't forget, we will need to remove that pesky door again!
+ wares=[wareforwareinwaresifware.key.lower()!="door"]
+ iware=int(raw_string)-1
+ ware=wares[iware]
+ value=ware.db.gold_valueor1
+ wealth=caller.db.goldor0
+ text="You inspect %s:\n\n%s"%(ware.key,ware.db.desc)
+
+ defbuy_ware_result(caller):
+ "This will be executed first when choosing to buy."
+ ifwealth>=value:
+ rtext="You pay %i gold and purchase %s!"% \
+ (value,ware.key)
+ caller.db.gold-=value
+ ware.move_to(caller,quiet=True)
+ else:
+ rtext="You cannot afford %i gold for %s!"% \
+ (value,ware.key)
+ caller.msg(rtext)
+
+ options=({"desc":"Buy %s for %s gold"% \
+ (ware.key,ware.db.gold_valueor1),
+ "goto":"menunode_shopfront",
+ "exec":buy_ware_result},
+ {"desc":"Look for something else",
+ "goto":"menunode_shopfront"})
+
+ returntext,options
+
+
+
In this menu node we make use of the raw_string argument to the node. This is the text the menu
+user entered on the previous node to get here. Since we only allow numbered options in our menu,
+raw_input must be an number for the player to get to this point. So we convert it to an integer
+index (menu lists start from 1, whereas Python indices always starts at 0, so we need to subtract
+1). We then use the index to get the corresponding item from storage.
+
We just show the customer the desc of the item. In a more elaborate setup you might want to show
+things like weapon damage and special stats here as well.
+
When the user choose the “buy” option, EvMenu will execute the exec instruction before we go
+back to the top node (the goto instruction). For this we make a little inline function
+buy_ware_result. EvMenu will call the function given to exec like any menu node but it does not
+need to return anything. In buy_ware_result we determine if the customer can afford the cost and
+give proper return messages. This is also where we actually move the bought item into the inventory
+of the customer.
We could in principle launch the shopping menu the moment a customer steps into our shop room, but
+this would probably be considered pretty annoying. It’s better to create a Command for
+customers to explicitly wanting to shop around.
+
# mygame/typeclasses/npcshop.py
+
+fromevenniaimportCommand
+
+classCmdBuy(Command):
+ """
+ Start to do some shopping
+
+ Usage:
+ buy
+ shop
+ browse
+
+ This will allow you to browse the wares of the
+ current shop and buy items you want.
+ """
+ key="buy"
+ aliases=("shop","browse")
+
+ deffunc(self):
+ "Starts the shop EvMenu instance"
+ evmenu.EvMenu(self.caller,
+ "typeclasses.npcshop",
+ startnode="menunode_shopfront")
+
+
+
This will launch the menu. The EvMenu instance is initialized with the path to this very module -
+since the only global functions available in this module are our menu nodes, this will work fine
+(you could also have put those in a separate module). We now just need to put this command in a
+CmdSet so we can add it correctly to the game:
There are really only two things that separate our shop from any other Room:
+
+
The shop has the storeroom Attribute set on it, pointing to a second (completely normal) room.
+
It has the ShopCmdSet stored on itself. This makes the buy command available to users entering
+the shop.
+
+
For testing we could easily add these features manually to a room using @py or other admin
+commands. Just to show how it can be done we’ll instead make a custom Typeclass for
+the shop room and make a small command that builders can use to build both the shop and the
+storeroom at once.
+
# bottom of mygame/typeclasses/npcshop.py
+
+fromevenniaimportDefaultRoom,DefaultExit,DefaultObject
+fromevennia.utils.createimportcreate_object
+
+# class for our front shop room
+classNPCShop(DefaultRoom):
+ defat_object_creation(self):
+ # we could also use add(ShopCmdSet, permanent=True)
+ self.cmdset.add_default(ShopCmdSet)
+ self.db.storeroom=None
+
+# command to build a complete shop (the Command base class
+# should already have been imported earlier in this file)
+classCmdBuildShop(Command):
+ """
+ Build a new shop
+
+ Usage:
+ @buildshop shopname
+
+ This will create a new NPCshop room
+ as well as a linked store room (named
+ simply <storename>-storage) for the
+ wares on sale. The store room will be
+ accessed through a locked door in
+ the shop.
+ """
+ key="@buildshop"
+ locks="cmd:perm(Builders)"
+ help_category="Builders"
+
+ deffunc(self):
+ "Create the shop rooms"
+ ifnotself.args:
+ self.msg("Usage: @buildshop <storename>")
+ return
+ # create the shop and storeroom
+ shopname=self.args.strip()
+ shop=create_object(NPCShop,
+ key=shopname,
+ location=None)
+ storeroom=create_object(DefaultRoom,
+ key="%s-storage"%shopname,
+ location=None)
+ shop.db.storeroom=storeroom
+ # create a door between the two
+ shop_exit=create_object(DefaultExit,
+ key="back door",
+ aliases=["storage","store room"],
+ location=shop,
+ destination=storeroom)
+ storeroom_exit=create_object(DefaultExit,
+ key="door",
+ location=storeroom,
+ destination=shop)
+ # make a key for accessing the store room
+ storeroom_key_name="%s-storekey"%shopname
+ storeroom_key=create_object(DefaultObject,
+ key=storeroom_key_name,
+ location=shop)
+ # only allow chars with this key to enter the store room
+ shop_exit.locks.add("traverse:holds(%s)"%storeroom_key_name)
+
+ # inform the builder about progress
+ self.caller.msg("The shop %s was created!"%shop)
+
+
+
Our typeclass is simple and so is our buildshop command. The command (which is for Builders only)
+just takes the name of the shop and builds the front room and a store room to go with it (always
+named "<shopname>-storage". It connects the rooms with a two-way exit. You need to add
+CmdBuildShop [to the default cmdset](./Adding-Command-Tutorial.md#step-2-adding-the-command-to-a-
+default-cmdset) before you can use it. Once having created the shop you can now @teleport to it or
+@open a new exit to it. You could also easily expand the above command to automatically create
+exits to and from the new shop from your current location.
+
To avoid customers walking in and stealing everything, we create a Lock on the storage
+door. It’s a simple lock that requires the one entering to carry an object named
+<shopname>-storekey. We even create such a key object and drop it in the shop for the new shop
+keeper to pick up.
+
+
If players are given the right to name their own objects, this simple lock is not very secure and
+you need to come up with a more robust lock-key solution.
+
+
+
We don’t add any descriptions to all these objects so looking “at” them will not be too thrilling.
+You could add better default descriptions as part of the @buildshop command or leave descriptions
+this up to the Builder.
We now have a functioning shop and an easy way for Builders to create it. All you need now is to
+@open a new exit from the rest of the game into the shop and put some sell-able items in the store
+room. Our shop does have some shortcomings:
+
+
For Characters to be able to buy stuff they need to also have the gold Attribute set on
+themselves.
+
We manually remove the “door” exit from our items for sale. But what if there are other unsellable
+items in the store room? What if the shop owner walks in there for example - anyone in the store
+could then buy them for 1 gold.
+
What if someone else were to buy the item we’re looking at just before we decide to buy it? It
+would then be gone and the counter be wrong - the shop would pass us the next item in the list.
+
+
Fixing these issues are left as an exercise.
+
If you want to keep the shop fully NPC-run you could add a Script to restock the shop’s
+store room regularly. This shop example could also easily be owned by a human Player (run for them
+by a hired NPC) - the shop owner would get the key to the store room and be responsible for keeping
+it well stocked.
Evennia offers many convenient ways to store object data, such as via Attributes or Scripts. This is
+sufficient for most use cases. But if you aim to build a large stand-alone system, trying to squeeze
+your storage requirements into those may be more complex than you bargain for. Examples may be to
+store guild data for guild members to be able to change, tracking the flow of money across a game-
+wide economic system or implement other custom game systems that requires the storage of custom data
+in a quickly accessible way. Whereas Tags or Scripts can handle many situations,
+sometimes things may be easier to handle by adding your own database model.
SQL-type databases (which is what Evennia supports) are basically highly optimized systems for
+retrieving text stored in tables. A table may look like this
Each line is considerably longer in your database. Each column is referred to as a “field” and every
+row is a separate object. You can check this out for yourself. If you use the default sqlite3
+database, go to your game folder and run
+
evennia dbshell
+
+
+
You will drop into the database shell. While there, try:
+
sqlite> .help # view help
+
+ sqlite> .tables # view all tables
+
+ # show the table field names for objects_objectdb
+ sqlite> .schema objects_objectdb
+
+ # show the first row from the objects_objectdb table
+ sqlite> select * from objects_objectdb limit 1;
+
+ sqlite> .exit
+
+
+
Evennia uses Django, which abstracts away the database SQL
+manipulation and allows you to search and manipulate your database entirely in Python. Each database
+table is in Django represented by a class commonly called a model since it describes the look of
+the table. In Evennia, Objects, Scripts, Channels etc are examples of Django models that we then
+extend and build on.
Here is how you add your own database table/models:
+
+
In Django lingo, we will create a new “application” - a subsystem under the main Evennia program.
+For this example we’ll call it “myapp”. Run the following (you need to have a working Evennia
+running before you do this, so make sure you have run the steps in [Getting Started](Getting-
+Started) first):
+
cd mygame/world
+ evennia startapp myapp
+
+
+
+
A new folder myapp is created. “myapp” will also be the name (the “app label”) from now on. We
+chose to put it in the world/ subfolder here, but you could put it in the root of your mygame if
+that makes more sense.
+
The myapp folder contains a few empty default files. What we are
+interested in for now is models.py. In models.py you define your model(s). Each model will be a
+table in the database. See the next section and don’t continue until you have added the models you
+want.
+
You now need to tell Evennia that the models of your app should be a part of your database
+scheme. Add this line to your mygame/server/conf/settings.pyfile (make sure to use the path where
+you put myapp and don’t forget the comma at the end of the tuple):
+
INSTALLED_APPS=INSTALLED_APPS+("world.myapp",)
+
+
+
+
From mygame/, run
+
evennia makemigrations myapp
+ evennia migrate
+
+
+
+
+
This will add your new database table to the database. If you have put your game under version
+control (if not, you should), don’t forget to gitaddmyapp/* to add all items
+to version control.
A Django model is the Python representation of a database table. It can be handled like any other
+Python class. It defines fields on itself, objects of a special type. These become the “columns”
+of the database table. Finally, you create new instances of the model to add new rows to the
+database.
+
We won’t describe all aspects of Django models here, for that we refer to the vast Django
+documentation on the subject. Here is a
+(very) brief example:
+
fromdjango.dbimportmodels
+
+classMyDataStore(models.Model):
+ "A simple model for storing some data"
+ db_key=models.CharField(max_length=80,db_index=True)
+ db_category=models.CharField(max_length=80,null=True,blank=True)
+ db_text=models.TextField(null=True,blank=True)
+ # we need this one if we want to be
+ # able to store this in an Evennia Attribute!
+ db_date_created=models.DateTimeField('date created',editable=False,
+ auto_now_add=True,db_index=True)
+
+
+
We create four fields: two character fields of limited length and one text field which has no
+maximum length. Finally we create a field containing the current time of us creating this object.
+
+
The db_date_created field, with exactly this name, is required if you want to be able to store
+instances of your custom model in an Evennia Attribute. It will automatically be set
+upon creation and can after that not be changed. Having this field will allow you to do e.g.
+obj.db.myinstance=mydatastore. If you know you’ll never store your model instances in Attributes
+the db_date_created field is optional.
+
+
You don’t have to start field names with db_, this is an Evennia convention. It’s nevertheless
+recommended that you do use db_, partly for clarity and consistency with Evennia (if you ever want
+to share your code) and partly for the case of you later deciding to use Evennia’s
+SharedMemoryModel parent down the line.
+
The field keyword db_index creates a database index for this field, which allows quicker
+lookups, so it’s recommended to put it on fields you know you’ll often use in queries. The
+null=True and blank=True keywords means that these fields may be left empty or set to the empty
+string without the database complaining. There are many other field types and keywords to define
+them, see django docs for more info.
+
Similar to using django-admin you
+are able to do evenniainspectdb to get an automated listing of model information for an existing
+database. As is the case with any model generating tool you should only use this as a starting
+point for your models.
To create a new row in your table, you instantiate the model and then call its save() method:
+
fromevennia.myappimportMyDataStore
+
+ new_datastore=MyDataStore(db_key="LargeSword",
+ db_category="weapons",
+ db_text="This is a huge weapon!")
+ # this is required to actually create the row in the database!
+ new_datastore.save()
+
+
+
+
Note that the db_date_created field of the model is not specified. Its flag at_now_add=True
+makes sure to set it to the current date when the object is created (it can also not be changed
+further after creation).
+
When you update an existing object with some new field value, remember that you have to save the
+object afterwards, otherwise the database will not update:
Evennia’s normal models don’t need to explicitly save, since they are based on SharedMemoryModel
+rather than the raw django model. This is covered in the next section.
Evennia doesn’t base most of its models on the raw django.db.models but on the Evennia base model
+evennia.utils.idmapper.models.SharedMemoryModel. There are two main reasons for this:
+
+
Ease of updating fields without having to explicitly call save()
+
On-object memory persistence and database caching
+
+
The first (and least important) point means that as long as you named your fields db_*, Evennia
+will automatically create field wrappers for them. This happens in the model’s
+Metaclass so there is no speed
+penalty for this. The name of the wrapper will be the same name as the field, minus the db_
+prefix. So the db_key field will have a wrapper property named key. You can then do:
+
my_datastore.key="Larger Sword"
+
+
+
and don’t have to explicitly call save() afterwards. The saving also happens in a more efficient
+way under the hood, updating only the field rather than the entire model using django optimizations.
+Note that if you were to manually add the property or method key to your model, this will be used
+instead of the automatic wrapper and allows you to fully customize access as needed.
+
To explain the second and more important point, consider the following example using the default
+Django model parent:
+
shield=MyDataStore.objects.get(db_key="SmallShield")
+ shield.cracked=True# where cracked is not a database field
+
The outcome of that last print statement is undefined! It could maybe randomly work but most
+likely you will get an AttributeError for not finding the cracked property. The reason is that
+cracked doesn’t represent an actual field in the database. It was just added at run-time and thus
+Django don’t care about it. When you retrieve your shield-match later there is no guarantee you
+will get back the same Python instance of the model where you defined cracked, even if you
+search for the same database object.
+
Evennia relies heavily on on-model handlers and other dynamically created properties. So rather than
+using the vanilla Django models, Evennia uses SharedMemoryModel, which levies something called
+idmapper. The idmapper caches model instances so that we will always get the same instance back
+after the first lookup of a given object. Using idmapper, the above example would work fine and you
+could retrieve your cracked property at any time - until you rebooted when all non-persistent data
+goes.
+
Using the idmapper is both more intuitive and more efficient per object; it leads to a lot less
+reading from disk. The drawback is that this system tends to be more memory hungry overall. So if
+you know that you’ll never need to add new properties to running instances or know that you will
+create new objects all the time yet rarely access them again (like for a log system), you are
+probably better off making “plain” Django models rather than using SharedMemoryModel and its
+idmapper.
+
To use the idmapper and the field-wrapper functionality you just have to have your model classes
+inherit from evennia.utils.idmapper.models.SharedMemoryModel instead of from the default
+django.db.models.Model:
+
fromevennia.utils.idmapper.modelsimportSharedMemoryModel
+
+classMyDataStore(SharedMemoryModel):
+ # the rest is the same as before, but db_* is important; these will
+ # later be settable as .key, .category, .text ...
+ db_key=models.CharField(max_length=80,db_index=True)
+ db_category=models.CharField(max_length=80,null=True,blank=True)
+ db_text=models.TextField(null=True,blank=True)
+ db_date_created=models.DateTimeField('date created',editable=False,
+ auto_now_add=True,db_index=True)
+
To search your new custom database table you need to use its database manager to build a query.
+Note that even if you use SharedMemoryModel as described in the previous section, you have to use
+the actual field names in the query, not the wrapper name (so db_key and not just key).
+
fromworld.myappimportMyDataStore
+
+ # get all datastore objects exactly matching a given key
+ matches=MyDataStore.objects.filter(db_key="Larger Sword")
+ # get all datastore objects with a key containing "sword"
+ # and having the category "weapons" (both ignoring upper/lower case)
+ matches2=MyDataStore.objects.filter(db_key__icontains="sword",
+ db_category__iequals="weapons")
+ # show the matching data (e.g. inside a command)
+ formatchinmatches2:
+ self.caller.msg(match.db_text)
+
Nicks, short for Nicknames is a system allowing an object (usually a Account) to
+assign custom replacement names for other game entities.
+
Nicks are not to be confused with Aliases. Setting an Alias on a game entity actually changes an
+inherent attribute on that entity, and everyone in the game will be able to use that alias to
+address the entity thereafter. A Nick on the other hand, is used to map a different way you
+alone can refer to that entity. Nicks are also commonly used to replace your input text which means
+you can create your own aliases to default commands.
+
Default Evennia use Nicks in three flavours that determine when Evennia actually tries to do the
+substitution.
+
+
inputline - replacement is attempted whenever you write anything on the command line. This is the
+default.
+
objects - replacement is only attempted when referring to an object
+
accounts - replacement is only attempted when referring an account
+
+
Here’s how to use it in the default command set (using the nick command):
+
nick ls = look
+
+
+
This is a good one for unix/linux users who are accustomed to using the ls command in their daily
+life. It is equivalent to nick/inputlinels=look.
+
nick/object mycar2 = The red sports car
+
+
+
With this example, substitutions will only be done specifically for commands expecting an object
+reference, such as
+
look mycar2
+
+
+
becomes equivalent to “lookTheredsportscar”.
+
nick/accounts tom = Thomas Johnsson
+
+
+
This is useful for commands searching for accounts explicitly:
+
@find *tom
+
+
+
One can use nicks to speed up input. Below we add ourselves a quicker way to build red buttons. In
+the future just writing rb will be enough to execute that whole long string.
+
nick rb = @create button:examples.red_button.RedButton
+
+
+
Nicks could also be used as the start for building a “recog” system suitable for an RP mud.
+
nick/account Arnold = The mysterious hooded man
+
+
+
The nick replacer also supports unix-style templating:
+
nick build $1 $2 = @create/drop $1;$2
+
+
+
This will catch space separated arguments and store them in the the tags $1 and $2, to be
+inserted in the replacement string. This example allows you to do buildboxcrate and have Evennia
+see @create/dropbox;crate. You may use any $ numbers between 1 and 99, but the markers must
+match between the nick pattern and the replacement.
+
+
If you want to catch “the rest” of a command argument, make sure to put a $ tag with no spaces
+to the right of it - it will then receive everything up until the end of the line.
Nicks are stored as the Nick database model and are referred from the normal Evennia
+object through the nicks property - this is known as the NickHandler. The NickHandler
+offers effective error checking, searches and conversion.
+
# A command/channel nick:
+ obj.nicks.add("greetjack","tell Jack = Hello pal!")
+
+ # An object nick:
+ obj.nicks.add("rose","The red flower",nick_type="object")
+
+ # An account nick:
+ obj.nicks.add("tom","Tommy Hill",nick_type="account")
+
+ # My own custom nick type (handled by my own game code somehow):
+ obj.nicks.add("hood","The hooded man",nick_type="my_identsystem")
+
+ # get back the translated nick:
+ full_name=obj.nicks.get("rose",nick_type="object")
+
+ # delete a previous set nick
+ object.nicks.remove("rose",nick_type="object")
+
+
+
In a command definition you can reach the nick handler through self.caller.nicks. See the nick
+command in evennia/commands/default/general.py for more examples.
+
As a last note, The Evennia channel alias systems are using nicks with the
+nick_type="channel" in order to allow users to create their own custom aliases to channels.
Internally, nicks are Attributes saved with the db_attrype set to “nick” (normal
+Attributes has this set to None).
+
The nick stores the replacement data in the Attribute.db_value field as a tuple with four fields
+(regex_nick,template_string,raw_nick,raw_template). Here regex_nick is the converted regex
+representation of the raw_nick and the template-string is a version of the raw_template
+prepared for efficient replacement of any $- type markers. The raw_nick and raw_template are
+basically the unchanged strings you enter to the nick command (with unparsed $ etc).
+
If you need to access the tuple for some reason, here’s how:
OOB, or Out-Of-Band, means sending data between Evennia and the user’s client without the user
+prompting it or necessarily being aware that it’s being passed. Common uses would be to update
+client health-bars, handle client button-presses or to display certain tagged text in a different
+window pane.
Inside Evennia, all server-client communication happens in the same way (so plain text is also an
+‘OOB message’ as far as Evennia is concerned). The message follows the Message Path.
+You should read up on that if you are unfamiliar with it. As the message travels along the path it
+has a standardized internal form: a tuple with a string, a tuple and a dict:
+
("cmdname", (args), {kwargs})
+
+
+
This is often referred to as an inputcommand or outputcommand, depending on the direction it’s
+traveling. The end point for an inputcommand, (the ‘Evennia-end’ of the message path) is a matching
+Inputfunc. This function is called as cmdname(session,*args,**kwargs) where
+session is the Session-source of the command. Inputfuncs can easily be added by the developer to
+support/map client commands to actions inside Evennia (see the inputfunc page for more
+details).
+
When a message is outgoing (at the ‘Client-end’ of the message path) the outputcommand is handled by
+a matching Outputfunc. This is responsible for converting the internal Evennia representation to a
+form suitable to send over the wire to the Client. Outputfuncs are hard-coded. Which is chosen and
+how it processes the outgoing data depends on the nature of the client it’s connected to. The only
+time one would want to add new outputfuncs is as part of developing support for a new Evennia
+Protocol.
A special case is the text input/outputfunc. It’s so common that it’s the default of the msg
+method. So these are equivalent:
+
caller.msg("Hello")
+ caller.msg(text="Hello")
+
+
+
You don’t have to specify the full output/input definition. So for example, if your particular
+command only needs kwargs, you can skip the (args) part. Like in the text case you can skip
+writing the tuple if there is only one arg … and so on - the input is pretty flexible. If there
+are no args at all you need to give the empty tuple msg(cmdname=(,) (giving None would mean a
+single argument None).
+
Which commands you can send depends on the client. If the client does not support an explicit OOB
+protocol (like many old/legacy MUD clients) Evennia can only send text to them and will quietly
+drop any other types of outputfuncs.
+
+
Remember that a given message may go to multiple clients with different capabilities. So unless
+you turn off telnet completely and only rely on the webclient, you should never rely on non-text
+OOB messages always reaching all targets.
+
+
Inputfuncs lists the default inputfuncs available to handle incoming OOB messages. To
+accept more you need to add more inputfuncs (see that page for more info).
By default telnet (and telnet+SSL) supports only the plain text outputcommand. Evennia however
+detects if the Client supports one of two MUD-specific OOB extensions to the standard telnet
+protocol - GMCP or MSDP. Evennia supports both simultaneously and will switch to the protocol the
+client uses. If the client supports both, GMCP will be used.
+
+
Note that for Telnet, text has a special status as the “in-band” operation. So the text
+outputcommand sends the text argument directly over the wire, without going through the OOB
+translations described below.
GMCP, the Generic Mud Communication Protocol sends data on the
+form cmdname+JSONdata. Here the cmdname is expected to be on the form “Package.Subpackage”.
+There could also be additional Sub-sub packages etc. The names of these ‘packages’ and ‘subpackages’
+are not that well standardized beyond what individual MUDs or companies have chosen to go with over
+the years. You can decide on your own package names, but here are what others are using:
Evennia will translate underscores to . and capitalize to fit the specification. So the
+outputcommand foo_bar will become a GMCP command-name Foo.Bar. A GMCP command “Foo.Bar” will be
+come foo_bar. To send a GMCP command that turns into an Evennia inputcommand without an
+underscore, use the Core package. So Core.Cmdname becomes just cmdname in Evennia and vice
+versa.
+
On the wire, a GMCP instruction for ("cmdname",("arg",),{}) will look like this:
+
IAC SB GMCP "cmdname" "arg" IAC SE
+
+
+
where all the capitalized words are telnet character constants specified in
+evennia/server/portal/telnet_oob.py. These are parsed/added by the protocol and we don’t include
+these in the listings below.
Since Evennia already supplies default inputfuncs that don’t match the names expected by the most
+common GMCP implementations we have a few hard-coded mappings for those:
MSDP, the Mud Server Data Protocol, is a competing standard
+to GMCP. The MSDP protocol page specifies a range of “recommended” available MSDP command names.
+Evennia does not support those - since MSDP doesn’t specify a special format for its command names
+(like GMCP does) the client can and should just call the internal Evennia inputfunc by its actual
+name.
+
MSDP uses Telnet character constants to package various structured data over the wire. MSDP supports
+strings, arrays (lists) and tables (dicts). These are used to define the cmdname, args and kwargs
+needed. When sending MSDP for ("cmdname",("arg",),{}) the resulting MSDP instruction will look
+like this:
+
IAC SB MSDP VAR cmdname VAL arg IAC SE
+
+
+
The various available MSDP constants like VAR (variable), VAL (value), ARRAYOPEN/ARRAYCLOSE
+and TABLEOPEN/TABLECLOSE are specified in evennia/server/portal/telnet_oob.
[cmdname,[],{}] | VAR cmdname VAL
+[cmdname,[arg],{}] | VAR cmdname VAL arg
+[cmdname,[args],{}] | VAR cmdname VAL ARRAYOPEN VAL arg VAL arg … ARRAYCLOSE
+[cmdname,[],{kwargs}] | VAR cmdname VAL TABLEOPEN VAR key VAL val … TABLECLOSE
+[cmdname,[args],{kwargs}] | VAR cmdname VAL ARRAYOPEN VAL arg VAL arg … ARRAYCLOSE VAR cmdname
+VAL TABLEOPEN VAR key VAL val … TABLECLOSE
+
Observe that VAR...VAL always identifies cmdnames, so if there are multiple arrays/dicts tagged
+with the same cmdname they will be appended to the args, kwargs of that inputfunc. Vice-versa, a
+different VAR...VAL (outside a table) will come out as a second, different command input.
Our web client uses pure JSON structures for all its communication, including text. This maps
+directly to the Evennia internal output/inputcommand, including eventual empty args/kwargs. So the
+same example ("cmdname",("arg",),{}) will be sent/received as a valid JSON structure
+
["cmdname, ["arg"], {}]
+
+
+
Since JSON is native to Javascript, this becomes very easy for the webclient to handle.
All in-game objects in Evennia, be it characters, chairs, monsters, rooms or hand grenades are
+represented by an Evennia Object. Objects form the core of Evennia and is probably what you’ll
+spend most time working with. Objects are Typeclassed entities.
An Evennia Object is, per definition, a Python class that includes evennia.DefaultObject among its
+parents. In mygame/typeclasses/objects.py there is already a class Object that inherits from
+DefaultObject and that you can inherit from. You can put your new typeclass directly in that
+module or you could organize your code in some other way. Here we assume we make a new module
+mygame/typeclasses/flowers.py:
+
# mygame/typeclasses/flowers.py
+
+ fromtypeclasses.objectsimportObject
+
+ classRose(Object):
+ """
+ This creates a simple rose object
+ """
+ defat_object_creation(self):
+ "this is called only once, when object is first created"
+ # add a persistent attribute 'desc'
+ # to object (silly example).
+ self.db.desc="This is a pretty rose with thorns."
+
+
+
You could save this in the mygame/typeclasses/objects.py (then you’d not need to import Object)
+or you can put it in a new module. Let’s say we do the latter, making a module
+typeclasses/flowers.py. Now you just need to point to the class Rose with the @create command
+to make a new rose:
+
@create/drop MyRose:flowers.Rose
+
+
+
What the @create command actually does is to use evennia.create_object. You can do the same
+thing yourself in code:
(The @create command will auto-append the most likely path to your typeclass, if you enter the
+call manually you have to give the full path to the class. The create.create_object function is
+powerful and should be used for all coded object creating (so this is what you use when defining
+your own building commands). Check out the ev.create_* functions for how to build other entities
+like Scripts).
+
This particular Rose class doesn’t really do much, all it does it make sure the attribute
+desc(which is what the look command looks for) is pre-set, which is pretty pointless since you
+will usually want to change this at build time (using the @desc command or using the
+Spawner). The Object typeclass offers many more hooks that is available
+to use though - see next section.
Beyond the properties assigned to all typeclassed objects (see that page for a list
+of those), the Object also has the following custom properties:
+
+
aliases - a handler that allows you to add and remove aliases from this object. Use
+aliases.add() to add a new alias and aliases.remove() to remove one.
+
location - a reference to the object currently containing this object.
+
home is a backup location. The main motivation is to have a safe place to move the object to if
+its location is destroyed. All objects should usually have a home location for safety.
+
destination - this holds a reference to another object this object links to in some way. Its
+main use is for Exits, it’s otherwise usually unset.
+
nicks - as opposed to aliases, a Nick holds a convenient nickname replacement for a
+real name, word or sequence, only valid for this object. This mainly makes sense if the Object is
+used as a game character - it can then store briefer shorts, example so as to quickly reference game
+commands or other characters. Use nicks.add(alias, realname) to add a new one.
+
account - this holds a reference to a connected Account controlling this object (if
+any). Note that this is set also if the controlling account is not currently online - to test if
+an account is online, use the has_account property instead.
+
sessions - if account field is set and the account is online, this is a list of all active
+sessions (server connections) to contact them through (it may be more than one if multiple
+connections are allowed in settings).
+
has_account - a shorthand for checking if an online account is currently connected to this
+object.
+
contents - this returns a list referencing all objects ‘inside’ this object (i,e. which has this
+object set as their location).
+
exits - this returns all objects inside this object that are Exits, that is, has the
+destination property set.
+
+
The last two properties are special:
+
+
cmdset - this is a handler that stores all command sets defined on the
+object (if any).
+
scripts - this is a handler that manages Scripts attached to the object (if any).
+
+
The Object also has a host of useful utility functions. See the function headers in
+src/objects/objects.py for their arguments and more details.
+
+
msg() - this function is used to send messages from the server to an account connected to this
+object.
+
msg_contents() - calls msg on all objects inside this object.
+
search() - this is a convenient shorthand to search for a specific object, at a given location
+or globally. It’s mainly useful when defining commands (in which case the object executing the
+command is named caller and one can do caller.search() to find objects in the room to operate
+on).
+
execute_cmd() - Lets the object execute the given string as if it was given on the command line.
+
move_to - perform a full move of this object to a new location. This is the main move method
+and will call all relevant hooks, do all checks etc.
+
clear_exits() - will delete all Exits to and from this object.
+
clear_contents() - this will not delete anything, but rather move all contents (except Exits) to
+their designated Home locations.
+
delete() - deletes this object, first calling clear_exits() and
+clear_contents().
+
+
The Object Typeclass defines many more hook methods beyond at_object_creation. Evennia calls
+these hooks at various points. When implementing your custom objects, you will inherit from the
+base parent and overload these hooks with your own custom code. See evennia.objects.objects for an
+updated list of all the available hooks or the API for DefaultObject
+here.
There are three special subclasses of Object in default Evennia - Characters, Rooms and
+Exits. The reason they are separated is because these particular object types are fundamental,
+something you will always need and in some cases requires some extra attention in order to be
+recognized by the game engine (there is nothing stopping you from redefining them though). In
+practice they are all pretty similar to the base Object.
Characters are objects controlled by Accounts. When a new Account
+logs in to Evennia for the first time, a new Character object is created and
+the Account object is assigned to the account attribute. A Character object
+must have a Default Commandset set on itself at
+creation, or the account will not be able to issue any commands! If you just
+inherit your own class from evennia.DefaultCharacter and make sure to use
+super() to call the parent methods you should be fine. In
+mygame/typeclasses/characters.py is an empty Character class ready for you
+to modify.
Rooms are the root containers of all other objects. The only thing really separating a room from
+any other object is that they have no location of their own and that default commands like @dig
+creates objects of this class - so if you want to expand your rooms with more functionality, just
+inherit from ev.DefaultRoom. In mygame/typeclasses/rooms.py is an empty Room class ready for
+you to modify.
Exits are objects connecting other objects (usually Rooms) together. An object named North or
+in might be an exit, as well as door, portal or jump out the window. An exit has two things
+that separate them from other objects. Firstly, their destination property is set and points to a
+valid object. This fact makes it easy and fast to locate exits in the database. Secondly, exits
+define a special Transit Command on themselves when they are created. This command is
+named the same as the exit object and will, when called, handle the practicalities of moving the
+character to the Exits’s destination - this allows you to just enter the name of the exit on its
+own to move around, just as you would expect.
+
The exit functionality is all defined on the Exit typeclass, so you could in principle completely
+change how exits work in your game (it’s not recommended though, unless you really know what you are
+doing). Exits are locked using an access_type called traverse and also make use of a few
+hook methods for giving feedback if the traversal fails. See evennia.DefaultExit for more info.
+In mygame/typeclasses/exits.py there is an empty Exit class for you to modify.
+
The process of traversing an exit is as follows:
+
+
The traversing obj sends a command that matches the Exit-command name on the Exit object. The
+cmdhandler detects this and triggers the command defined on the Exit. Traversal always
+involves the “source” (the current location) and the destination (this is stored on the Exit
+object).
+
The Exit command checks the traverse lock on the Exit object
+
The Exit command triggers at_traverse(obj,destination) on the Exit object.
+
In at_traverse, object.move_to(destination) is triggered. This triggers the following hooks,
+in order:
+
+
obj.at_before_move(destination) - if this returns False, move is aborted.
+
origin.at_object_leave(obj,destination)
+
obj.announce_move_from(destination)
+
Move is performed by changing obj.location from source location to destination.
+
obj.announce_move_to(source)
+
destination.at_object_receive(obj,source)
+
obj.at_after_move(source)
+
+
+
On the Exit object, at_after_traverse(obj,source) is triggered.
+
+
If the move fails for whatever reason, the Exit will look for an Attribute err_traverse on itself
+and display this as an error message. If this is not found, the Exit will instead call
+at_failed_traverse(obj) on itself.
Evennia development can be made without any Internet connection beyond fetching updates. At some
+point however, you are likely to want to make your game visible online, either as part opening it to
+the public or to allow other developers or beta testers access to it.
Accessing your Evennia server from the outside is not hard on its own. Any issues are usually due to
+the various security measures your computer, network or hosting service has. These will generally
+(and correctly) block outside access to servers on your machine unless you tell them otherwise.
+
We will start by showing how to host your server on your own local computer. Even if you plan to
+host your “real” game on a remote host later, setting it up locally is useful practice. We cover
+remote hosting later in this document.
+
Out of the box, Evennia uses three ports for outward communication. If your computer has a firewall,
+these should be open for in/out communication (and only these, other ports used by Evennia are
+internal to your computer only).
+
+
4000, telnet, for traditional mud clients
+
4001, HTTP for the website)
+
4002, websocket, for the web client
+
+
Evennia will by default accept incoming connections on all interfaces (0.0.0.0) so in principle
+anyone knowing the ports to use and has the IP address to your machine should be able to connect to
+your game.
+
+
Make sure Evennia is installed and that you have activated the virtualenv. Start the server with
+evenniastart--log. The --log (or -l) will make sure that the logs are echoed to the
+terminal.
+
+
+
Note: If you need to close the log-view, use Ctrl-C. Use just evennia--log on its own to
+start tailing the logs again.
+
+
+
Make sure you can connect with your web browser to http://localhost:4001 or, alternatively,
+http://127.0.0.1:4001 which is the same thing. You should get your Evennia web site and be able to
+play the game in the web client. Also check so that you can connect with a mud client to host
+localhost, port 4000 or host 127.0.0.1, port 4000.
+
Google for “my ip” or use any online service to figure out
+what your “outward-facing” IP address is. For our purposes, let’s say your outward-facing IP is
+203.0.113.0.
+
Next try your outward-facing IP by opening http://203.0.113.0:4001 in a browser. If this works,
+that’s it! Also try telnet, with the server set to 203.0.113.0 and port 4000. However, most
+likely it will not work. If so, read on.
+
If your computer has a firewall, it may be blocking the ports we need (it may also block telnet
+overall). If so, you need to open the outward-facing ports to in/out communication. See the
+manual/instructions for your firewall software on how to do this. To test you could also temporarily
+turn off your firewall entirely to see if that was indeed the problem.
+
Another common problem for not being able to connect is that you are using a hardware router
+(like a wifi router). The router sits ‘between’ your computer and the Internet. So the IP you find
+with Google is the router’s IP, not that of your computer. To resolve this you need to configure
+your router to forward data it gets on its ports to the IP and ports of your computer sitting in
+your private network. How to do this depends on the make of your router; you usually configure it
+using a normal web browser. In the router interface, look for “Port forwarding” or maybe “Virtual
+server”. If that doesn’t work, try to temporarily wire your computer directly to the Internet outlet
+(assuming your computer has the ports for it). You’ll need to check for your IP again. If that
+works, you know the problem is the router.
+
+
+
Note: If you need to reconfigure a router, the router’s Internet-facing ports do not have to
+have to have the same numbers as your computer’s (and Evennia’s) ports! For example, you might want
+to connect Evennia’s outgoing port 4001 to an outgoing router port 80 - this is the port HTTP
+requests use and web browsers automatically look for - if you do that you could go to
+http://203.0.113.0 without having to add the port at the end. This would collide with any other
+web services you are running through this router though.
You can connect Evennia to the Internet without any changes to your settings. The default settings
+are easy to use but are not necessarily the safest. You can customize your online presence in your
+settings file. To have Evennia recognize changed port settings you have
+to do a full evenniareboot to also restart the Portal and not just the Server component.
+
Below is an example of a simple set of settings, mostly using the defaults. Evennia will require
+access to five computer ports, of which three (only) should be open to the outside world. Below we
+continue to assume that our server address is 203.0.113.0.
+
# in mygame/server/conf/settings.py
+
+SERVERNAME="MyGame"
+
+# open to the internet: 4000, 4001, 4002
+# closed to the internet (internal use): 4005, 4006
+TELNET_PORTS=[4000]
+WEBSOCKET_CLIENT_PORT=4002
+WEBSERVER_PORTS=[(4001,4005)]
+AMP_PORT=4006
+
+# Optional - security measures limiting interface access
+# (don't set these before you know things work without them)
+TELNET_INTERFACES=['203.0.113.0']
+WEBSOCKET_CLIENT_INTERFACE='203.0.113.0'
+ALLOWED_HOSTS=[".mymudgame.com"]
+
+# uncomment if you want to lock the server down for maintenance.
+# LOCKDOWN_MODE = True
+
+
+
+
Read on for a description of the individual settings.
# Required. Change to whichever outgoing Telnet port(s)
+# you are allowed to use on your host.
+TELNET_PORTS=[4000]
+# Optional for security. Restrict which telnet
+# interfaces we should accept. Should be set to your
+# outward-facing IP address(es). Default is ´0.0.0.0´
+# which accepts all interfaces.
+TELNET_INTERFACES=['0.0.0.0']
+
+
+
The TELNET_* settings are the most important ones for getting a traditional base game going. Which
+IP addresses you have available depends on your server hosting solution (see the next sections).
+Some hosts will restrict which ports you are allowed you use so make sure to check.
# Required. This is a list of tuples
+# (outgoing_port, internal_port). Only the outgoing
+# port should be open to the world!
+# set outgoing port to 80 if you want to run Evennia
+# as the only web server on your machine (if available).
+WEBSERVER_PORTS=[(4001,4005)]
+# Optional for security. Change this to the IP your
+# server can be reached at (normally the same
+# as TELNET_INTERFACES)
+WEBSERVER_INTERFACES=['0.0.0.0']
+# Optional for security. Protects against
+# man-in-the-middle attacks. Change it to your server's
+# IP address or URL when you run a production server.
+ALLOWED_HOSTS=['*']
+
+
+
The web server is always configured with two ports at a time. The outgoing port (4001 by
+default) is the port external connections can use. If you don’t want users to have to specify the
+port when they connect, you should set this to 80 - this however only works if you are not running
+any other web server on the machine.
+The internal port (4005 by default) is used internally by Evennia to communicate between the
+Server and the Portal. It should not be available to the outside world. You usually only need to
+change the outgoing port unless the default internal port is clashing with some other program.
# Required. Change this to the main IP address of your server.
+WEBSOCKET_CLIENT_INTERFACE='0.0.0.0'
+# Optional and needed only if using a proxy or similar. Change
+# to the IP or address where the client can reach
+# your server. The ws:// part is then required. If not given, the client
+# will use its host location.
+WEBSOCKET_CLIENT_URL=""
+# Required. Change to a free port for the websocket client to reach
+# the server on. This will be automatically appended
+# to WEBSOCKET_CLIENT_URL by the web client.
+WEBSOCKET_CLIENT_PORT=4002
+
+
+
The websocket-based web client needs to be able to call back to the server, and these settings must
+be changed for it to find where to look. If it cannot find the server you will get an warning in
+your browser’s Console (in the dev tools of the browser), and the client will revert to the AJAX-
+based of the client instead, which tends to be slower.
# Optional public facing. Only allows SSL connections (off by default).
+SSL_PORTS=[4003]
+SSL_INTERFACES=['0.0.0.0']
+# Optional public facing. Only if you allow SSH connections (off by default).
+SSH_PORTS=[4004]
+SSH_INTERFACES=['0.0.0.0']
+# Required private. You should only change this if there is a clash
+# with other services on your host. Should NOT be open to the
+# outside world.
+AMP_PORT=4006
+
+
+
The AMP_PORT is required to work, since this is the internal port linking Evennia’s
+Server and Portal components together. The other ports are encrypted ports that may be
+useful for custom protocols but are otherwise not used.
When you test things out and check configurations you may not want players to drop in on you.
+Similarly, if you are doing maintenance on a live game you may want to take it offline for a while
+to fix eventual problems without risking people connecting. To do this, stop the server with
+evenniastop and add LOCKDOWN_MODE=True to your settings file. When you start the server
+again, your game will only be accessible from localhost.
Once your game is online you should make sure to register it with the Evennia Game
+Index. Registering with the index will help people find your server,
+drum up interest for your game and also shows people that Evennia is being used. You can do this
+even if you are just starting development - if you don’t give any telnet/web address it will appear
+as Not yet public and just be a teaser. If so, pick pre-alpha as the development status.
+
To register, stand in your game dir, run
+
evennia connections
+
+
+
and follow the instructions. See the Game index page for more details.
SSL can be very useful for web clients. It will protect the credentials and gameplay of your users
+over a web client if they are in a public place, and your websocket can also be switched to WSS for
+the same benefit. SSL certificates used to cost money on a yearly basis, but there is now a program
+that issues them for free with assisted setup to make the entire process less painful.
+
Options that may be useful in combination with an SSL proxy:
+
# See above for the section on Lockdown Mode.
+# Useful for a proxy on the public interface connecting to Evennia on localhost.
+LOCKDOWN_MODE=True
+
+# Have clients communicate via wss after connecting with https to port 4001.
+# Without this, you may get DOMException errors when the browser tries
+# to create an insecure websocket from a secure webpage.
+WEBSOCKET_CLIENT_URL="wss://fqdn:4002"
+
Let’s Encrypt is a certificate authority offering free certificates to
+secure a website with HTTPS. To get started issuing a certificate for your web server using Let’s
+Encrypt, see these links:
The CertBot Client is a program for automatically obtaining a
+certificate, use it and maintain it with your website.
+
+
Also, on Freenode visit the #letsencrypt channel for assistance from the community. For an
+additional resource, Let’s Encrypt has a very active community
+forum.
The only process missing from all of the above documentation is how to pass verification. This is
+how Let’s Encrypt verifies that you have control over your domain (not necessarily ownership, it’s
+Domain Validation (DV)). This can be done either with configuring a certain path on your web server
+or through a TXT record in your DNS. Which one you will want to do is a personal preference, but can
+also be based on your hosting choice. In a controlled/cPanel environment, you will most likely have
+to use DNS verification.
What we showed above is by far the simplest and probably cheapest option: Run Evennia on your own
+home computer. Moreover, since Evennia is its own web server, you don’t need to install anything
+extra to have a website.
+
Advantages
+
+
Free (except for internet costs and the electrical bill).
+
Full control over the server and hardware (it sits right there!).
+
Easy to set up.
+
Suitable for quick setups - e.g. to briefly show off results to your collaborators.
+
+
Disadvantages
+
+
You need a good internet connection, ideally without any upload/download limits/costs.
+
If you want to run a full game this way, your computer needs to always be on. It could be noisy,
+and as mentioned, the electrical bill must be considered.
+
No support or safety - if your house burns down, so will your game. Also, you are yourself
+responsible for doing regular backups.
+
Potentially not as easy if you don’t know how to open ports in your firewall or router.
+
Home IP numbers are often dynamically allocated, so for permanent online time you need to set up a
+DNS to always re-point to the right place (see below).
+
You are personally responsible for any use/misuse of your internet connection– though unlikely
+(but not impossible) if running your server somehow causes issues for other customers on the
+network, goes against your ISP’s terms of service (many ISPs insist on upselling you to a business-
+tier connection) or you are the subject of legal action by a copyright holder, you may find your
+main internet connection terminated as a consequence.
The first section of this page describes how to do this
+and allow users to connect to the IP address of your machine/router.
+
A complication with using a specific IP address like this is that your home IP might not remain the
+same. Many ISPs (Internet Service Providers) allocates a dynamic IP to you which could change at
+any time. When that happens, that IP you told people to go to will be worthless. Also, that long
+string of numbers is not very pretty, is it? It’s hard to remember and not easy to use in marketing
+your game. What you need is to alias it to a more sensible domain name - an alias that follows you
+around also when the IP changes.
+
+
To set up a domain name alias, we recommend starting with a free domain name from
+FreeDNS. Once you register there (it’s free) you have access to tens
+of thousands domain names that people have “donated” to allow you to use for your own sub domain.
+For example, strangled.net is one of those available domains. So tying our IP address to
+strangled.net using the subdomain evennia would mean that one could henceforth direct people to
+http://evennia.strangled.net:4001 for their gaming needs - far easier to remember!
+
So how do we make this new, nice domain name follow us also if our IP changes? For this we need
+to set up a little program on our computer. It will check whenever our ISP decides to change our IP
+and tell FreeDNS that. There are many alternatives to be found from FreeDNS:s homepage, one that
+works on multiple platforms is inadyn. Get it from their page or,
+in Linux, through something like apt-getinstallinadyn.
+
Next, you login to your account on FreeDNS and go to the
+Dynamic page. You should have a list of your subdomains. Click
+the DirectURL link and you’ll get a page with a text message. Ignore that and look at the URL of
+the page. It should be ending in a lot of random letters. Everything after the question mark is your
+unique “hash”. Copy this string.
+
You now start inadyn with the following command (Linux):
where <my.domain> would be evennia.strangled.net and <hash> the string of numbers we copied
+from FreeDNS. The & means we run in the background (might not be valid in other operating
+systems). inadyn will henceforth check for changes every 60 seconds. You should put the inadyn
+command string in a startup script somewhere so it kicks into gear whenever your computer starts.
Your normal “web hotel” will probably not be enough to run Evennia. A web hotel is normally aimed at
+a very specific usage - delivering web pages, at the most with some dynamic content. The “Python
+scripts” they refer to on their home pages are usually only intended to be CGI-like scripts launched
+by their webserver. Even if they allow you shell access (so you can install the Evennia dependencies
+in the first place), resource usage will likely be very restricted. Running a full-fledged game
+server like Evennia will probably be shunned upon or be outright impossible. If you are unsure,
+contact your web hotel and ask about their policy on you running third-party servers that will want
+to open custom ports.
+
The options you probably need to look for are shell account services, VPS:es or Cloud
+services. A “Shell account” service means that you get a shell account on a server and can log in
+like any normal user. By contrast, a VPS (Virtual Private Server) service usually means that you
+get root access, but in a virtual machine. There are also Cloud-type services which allows for
+starting up multiple virtual machines and pay for what resources you use.
+
Advantages
+
+
Shell accounts/VPS/clouds offer more flexibility than your average web hotel - it’s the ability to
+log onto a shared computer away from home.
+
Usually runs a Linux flavor, making it easy to install Evennia.
+
Support. You don’t need to maintain the server hardware. If your house burns down, at least your
+game stays online. Many services guarantee a certain level of up-time and also do regular backups
+for you. Make sure to check, some offer lower rates in exchange for you yourself being fully
+responsible for your data/backups.
+
Usually offers a fixed domain name, so no need to mess with IP addresses.
+
May have the ability to easily deploy docker versions of evennia
+and/or your game.
+
+
Disadvantages
+
+
Might be pretty expensive (more so than a web hotel). Note that Evennia will normally need at
+least 100MB RAM and likely much more for a large production game.
+
Linux flavors might feel unfamiliar to users not used to ssh/PuTTy and the Linux command line.
+
You are probably sharing the server with many others, so you are not completely in charge. CPU
+usage might be limited. Also, if the server people decides to take the server down for maintenance,
+you have no choice but to sit it out (but you’ll hopefully be warned ahead of time).
Firstly, if you are familiar with server infrastructure, consider using [Docker](Running-Evennia-in-
+Docker) to deploy your game to the remote server; it will likely ease installation and deployment.
+Docker images may be a little confusing if you are completely new to them though.
+
If not using docker, and assuming you know how to connect to your account over ssh/PuTTy, you should
+be able to follow the Setup Quickstart instructions normally. You only need Python
+and GIT pre-installed; these should both be available on any servers (if not you should be able to
+easily ask for them to be installed). On a VPS or Cloud service you can install them yourself as
+needed.
+
If virtualenv is not available and you can’t get it, you can download it (it’s just a single file)
+from the virtualenv pypi. Using virtualenv you can
+install everything without actually needing to have further root access. Ports might be an issue,
+so make sure you know which ports are available to use and reconfigure Evennia accordingly.
To find commercial solutions, browse the web for “shell access”, “VPS” or “Cloud services” in your
+region. You may find useful offers for “low cost” VPS hosting on Low End Box. The associated
+Low End Talk forum can be useful for health checking the many small businesses that offer
+“value” hosting, and occasionally for technical suggestions.
+
There are all sorts of services available. Below are some international suggestions offered by
+Evennia users:
Private hobby provider so don’t assume backups or expect immediate support. To ask for an account,connect with a MUD client to rostdev.mushpark.com, port 4201 and ask for “Jarin”.
You can get a $50 credit if you use the referral link https://m.do.co/c/8f64fec2670c - if you do, once you’ve had it long enough to have paid $25 we will get that as a referral bonus to help Evennia development.
Dedicated MUD host with very limited memory offerings. As for 2017, runs a 13 years old Python version (2.4) so you’d need to either convince them to update or compile yourself. Note that Evennia needs at least the “Deluxe” package (50MB RAM) and probably a lot higher for a production game. This host is not recommended for Evennia.
If you are interested in running Evennia in the online dev environment Cloud9, you
+can spin it up through their normal online setup using the Evennia Linux install instructions. The
+one extra thing you will have to do is update mygame/server/conf/settings.py and add
+WEBSERVER_PORTS=[(8080,4001)]. This will then let you access the web server and do everything
+else as normal.
+
Note that, as of December 2017, Cloud9 was re-released by Amazon as a service within their AWS cloud
+service offering. New customers entitled to the 1 year AWS “free tier” may find it provides
+sufficient resources to operate a Cloud9 development environment without charge.
+https://aws.amazon.com/cloud9/
Parsing command arguments, theory and best practices¶
+
This tutorial will elaborate on the many ways one can parse command arguments. The first step after
+adding a command usually is to parse its arguments. There are lots of
+ways to do it, but some are indeed better than others and this tutorial will try to present them.
+
If you’re a Python beginner, this tutorial might help you a lot. If you’re already familiar with
+Python syntax, this tutorial might still contain useful information. There are still a lot of
+things I find in the standard library that come as a surprise, though they were there all along.
+This might be true for others.
I’m going to talk about command arguments and parsing a lot in this tutorial. So let’s be sure we
+talk about the same thing before going any further:
+
+
A command is an Evennia object that handles specific user input.
+
+
For instance, the default look is a command. After having created your Evennia game, and
+connected to it, you should be able to type look to see what’s around. In this context, look is
+a command.
+
+
Command arguments are additional text passed after the command.
+
+
Following the same example, you can type lookself to look at yourself. In this context, self
+is the text specified after look. "self" is the argument to the look command.
+
Part of our task as a game developer is to connect user inputs (mostly commands) with actions in the
+game. And most of the time, entering commands is not enough, we have to rely on arguments for
+specifying actions with more accuracy.
+
Take the say command. If you couldn’t specify what to say as a command argument (sayhello!),
+you would have trouble communicating with others in the game. One would need to create a different
+command for every kind of word or sentence, which is, of course, not practical.
+
Last thing: what is parsing?
+
+
In our case, parsing is the process by which we convert command arguments into something we can
+work with.
+
+
We don’t usually use the command argument as is (which is just text, of type str in Python). We
+need to extract useful information. We might want to ask the user for a number, or the name of
+another character present in the same room. We’re going to see how to do all that now.
In object terms, when you write a command in Evennia (when you write the Python class), the
+arguments are stored in the args attribute. Which is to say, inside your func method, you can
+access the command arguments in self.args.
The lines starting with > indicate what you enter into your client. The other lines are what
+you receive from the game server.
+
+
Notice two things here:
+
+
The left space between our command key (“test”, here) and our command argument is not removed.
+That’s why there are two spaces in our output at line 2. Try entering something like “testok”.
+
Even if you don’t enter command arguments, the command will still be called with an empty string
+in self.args.
+
+
Perhaps a slight modification to our code would be appropriate to see what’s happening. We will
+force Python to display the command arguments as a debug string using a little shortcut.
+
classCmdTest(Command):
+
+ """
+ Test command.
+
+ Syntax:
+ test [argument]
+
+ Enter any argument after test.
+
+ """
+
+ key="test"
+
+ deffunc(self):
+ self.msg(f"You have entered: {self.args!r}.")
+
+
+
The only line we have changed is the last one, and we have added !r between our braces to tell
+Python to print the debug version of the argument (the repr-ed version). Let’s see the result:
+
>testWhatever
+Youhaveentered:' Whatever'.
+>test
+Youhaveentered:''.
+>testAndsomethingwith'?
+Youhaveentered:" And something with '?".
+
+
+
This displays the string in a way you could see in the Python interpreter. It might be easier to
+read… to debug, anyway.
+
I insist so much on that point because it’s crucial: the command argument is just a string (of type
+str) and we will use this to parse it. What you will see is mostly not Evennia-specific, it’s
+Python-specific and could be used in any other project where you have the same need.
As you’ve seen, our command arguments are stored with the space. And the space between the command
+and the arguments is often of no importance.
+
+
Why is it ever there?
+
+
Evennia will try its best to find a matching command. If the user enters your command key with
+arguments (but omits the space), Evennia will still be able to find and call the command. You might
+have seen what happened if the user entered testok. In this case, testok could very well be a
+command (Evennia checks for that) but seeing none, and because there’s a test command, Evennia
+calls it with the arguments "ok".
+
But most of the time, we don’t really care about this left space, so you will often see code to
+remove it. There are different ways to do it in Python, but a command use case is the strip
+method on str and its cousins, lstrip and rstrip.
+
+
strip: removes one or more characters (either spaces or other characters) from both ends of the
+string.
+
lstrip: same thing but only removes from the left end (left strip) of the string.
+
rstrip: same thing but only removes from the right end (right strip) of the string.
+
+
Some Python examples might help:
+
>>> ' this is '.strip()# remove spaces by default
+'this is'
+>>> " What if I'm right? ".lstrip()# strip spaces from the left
+"What if I'm right? "
+>>> 'Looks good to me...'.strip('.')# removes '.'
+'Looks good to me'
+>>> '"Now, what is it?"'.strip('"?')# removes '"' and '?' from both ends
+'Now, what is it'
+
+
+
Usually, since we don’t need the space separator, but still want our command to work if there’s no
+separator, we call lstrip on the command arguments:
+
classCmdTest(Command):
+
+ """
+ Test command.
+
+ Syntax:
+ test [argument]
+
+ Enter any argument after test.
+
+ """
+
+ key="test"
+
+ defparse(self):
+ """Parse arguments, just strip them."""
+ self.args=self.args.lstrip()
+
+ deffunc(self):
+ self.msg(f"You have entered: {self.args!r}.")
+
+
+
+
We are now beginning to override the command’s parse method, which is typically useful just for
+argument parsing. This method is executed before func and so self.args in func() will contain
+our self.args.lstrip().
+
+
Let’s try it:
+
>testWhatever
+Youhaveentered:'Whatever'.
+>test
+Youhaveentered:''.
+>testAndsomethingwith'?
+Youhaveentered:"And something with '?".
+>testAndsomethingwithlotsofspaces
+Youhaveentered:'And something with lots of spaces'.
+
+
+
Spaces at the end of the string are kept, but all spaces at the beginning are removed:
+
+
strip, lstrip and rstrip without arguments will strip spaces, line breaks and other common
+separators. You can specify one or more characters as a parameter. If you specify more than one
+character, all of them will be stripped from your original string.
As pointed out, self.args is a string (of type str). What if we want the user to enter a
+number?
+
Let’s take a very simple example: creating a command, roll, that allows to roll a six-sided die.
+The player has to guess the number, specifying the number as argument. To win, the player has to
+match the number with the die. Let’s see an example:
+
> roll 3
+You roll a die. It lands on the number 4.
+You played 3, you have lost.
+> dice 1
+You roll a die. It lands on the number 2.
+You played 1, you have lost.
+> dice 1
+You roll a die. It lands on the number 1.
+You played 1, you have won!
+
+
+
If that’s your first command, it’s a good opportunity to try to write it. A command with a simple
+and finite role always is a good starting choice. Here’s how we could (first) write it… but it
+won’t work as is, I warn you:
+
fromrandomimportrandint
+
+fromevenniaimportCommand
+
+classCmdRoll(Command):
+
+ """
+ Play random, enter a number and try your luck.
+
+ Usage:
+ roll <number>
+
+ Enter a valid number as argument. A random die will be rolled and you
+ will win if you have specified the correct number.
+
+ Example:
+ roll 3
+
+ """
+
+ key="roll"
+
+ defparse(self):
+ """Convert the argument to a number."""
+ self.args=self.args.lstrip()
+
+ deffunc(self):
+ # Roll a random die
+ figure=randint(1,6)# return a pseudo-random number between 1 and 6, including both
+ self.msg(f"You roll a die. It lands on the number {figure}.")
+
+ ifself.args==figure:# THAT WILL BREAK!
+ self.msg(f"You played {self.args}, you have won!")
+ else:
+ self.msg(f"You played {self.args}, you have lost.")
+
+
+
If you try this code, Python will complain that you try to compare a number with a string: figure
+is a number and self.args is a string and can’t be compared as-is in Python. Python doesn’t do
+“implicit converting” as some languages do. By the way, this might be annoying sometimes, and other
+times you will be glad it tries to encourage you to be explicit rather than implicit about what to
+do. This is an ongoing debate between programmers. Let’s move on!
+
So we need to convert the command argument from a str into an int. There are a few ways to do
+it. But the proper way is to try to convert and deal with the ValueError Python exception.
+
Converting a str into an int in Python is extremely simple: just use the int function, give it
+the string and it returns an integer, if it could. If it can’t, it will raise ValueError. So
+we’ll need to catch that. However, we also have to indicate to Evennia that, should the number be
+invalid, no further parsing should be done. Here’s a new attempt at our command with this
+converting:
+
fromrandomimportrandint
+
+fromevenniaimportCommand,InterruptCommand
+
+classCmdRoll(Command):
+
+ """
+ Play random, enter a number and try your luck.
+
+ Usage:
+ roll <number>
+
+ Enter a valid number as argument. A random die will be rolled and you
+ will win if you have specified the correct number.
+
+ Example:
+ roll 3
+
+ """
+
+ key="roll"
+
+ defparse(self):
+ """Convert the argument to number if possible."""
+ args=self.args.lstrip()
+
+ # Convert to int if possible
+ # If not, raise InterruptCommand. Evennia will catch this
+ # exception and not call the 'func' method.
+ try:
+ self.entered=int(args)
+ exceptValueError:
+ self.msg(f"{args} is not a valid number.")
+ raiseInterruptCommand
+
+ deffunc(self):
+ # Roll a random die
+ figure=randint(1,6)# return a pseudo-random number between 1 and 6, including both
+ self.msg(f"You roll a die. It lands on the number {figure}.")
+
+ ifself.entered==figure:
+ self.msg(f"You played {self.entered}, you have won!")
+ else:
+ self.msg(f"You played {self.entered}, you have lost.")
+
+
+
Before enjoying the result, let’s examine the parse method a little more: what it does is try to
+convert the entered argument from a str to an int. This might fail (if a user enters rollsomething). In such a case, Python raises a ValueError exception. We catch it in our
+try/except block, send a message to the user and raise the InterruptCommand exception in
+response to tell Evennia to not run func(), since we have no valid number to give it.
+
In the func method, instead of using self.args, we use self.entered which we have defined in
+our parse method. You can expect that, if func() is run, then self.entered contains a valid
+number.
+
If you try this command, it will work as expected this time: the number is converted as it should
+and compared to the die roll. You might spend some minutes playing this game. Time out!
+
Something else we could want to address: in our small example, we only want the user to enter a
+positive number between 1 and 6. And the user can enter roll0 or roll-8 or roll208 for
+that matter, the game still works. It might be worth addressing. Again, you could write a
+condition to do that, but since we’re catching an exception, we might end up with something cleaner
+by grouping:
+
fromrandomimportrandint
+
+fromevenniaimportCommand,InterruptCommand
+
+classCmdRoll(Command):
+
+ """
+ Play random, enter a number and try your luck.
+
+ Usage:
+ roll <number>
+
+ Enter a valid number as argument. A random die will be rolled and you
+ will win if you have specified the correct number.
+
+ Example:
+ roll 3
+
+ """
+
+ key="roll"
+
+ defparse(self):
+ """Convert the argument to number if possible."""
+ args=self.args.lstrip()
+
+ # Convert to int if possible
+ try:
+ self.entered=int(args)
+ ifnot1<=self.entered<=6:
+ # self.entered is not between 1 and 6 (including both)
+ raiseValueError
+ exceptValueError:
+ self.msg(f"{args} is not a valid number.")
+ raiseInterruptCommand
+
+ deffunc(self):
+ # Roll a random die
+ figure=randint(1,6)# return a pseudo-random number between 1 and 6, including both
+ self.msg(f"You roll a die. It lands on the number {figure}.")
+
+ ifself.entered==figure:
+ self.msg(f"You played {self.entered}, you have won!")
+ else:
+ self.msg(f"You played {self.entered}, you have lost.")
+
+
+
Using grouped exceptions like that makes our code easier to read, but if you feel more comfortable
+checking, afterward, that the number the user entered is in the right range, you can do so in a
+latter condition.
+
+
Notice that we have updated our parse method only in this last attempt, not our func() method
+which remains the same. This is one goal of separating argument parsing from command processing,
+these two actions are best kept isolated.
Often a command expects several arguments. So far, in our example with the “roll” command, we only
+expect one argument: a number and just a number. What if we want the user to specify several
+numbers? First the number of dice to roll, then the guess?
+
+
You won’t win often if you roll 5 dice but that’s for the example.
+
+
So we would like to interpret a command like this:
+
> roll 3 12
+
+
+
(To be understood: roll 3 dice, my guess is the total number will be 12.)
+
What we need is to cut our command argument, which is a str, break it at the space (we use the
+space as a delimiter). Python provides the str.split method which we’ll use. Again, here are
+some examples from the Python interpreter:
+
>>> args = "3 12"
+>>> args.split(" ")
+['3', '12']
+>>> args = "a command with several arguments"
+>>> args.split(" ")
+['a', 'command', 'with', 'several', 'arguments']
+>>>
+
+
+
As you can see, str.split will “convert” our strings into a list of strings. The specified
+argument ("" in our case) is used as delimiter. So Python browses our original string. When it
+sees a delimiter, it takes whatever is before this delimiter and append it to a list.
+
The point here is that str.split will be used to split our argument. But, as you can see from the
+above output, we can never be sure of the length of the list at this point:
Again we could use a condition to check the number of split arguments, but Python offers a better
+approach, making use of its exception mechanism. We’ll give a second argument to str.split, the
+maximum number of splits to do. Let’s see an example, this feature might be confusing at first
+glance:
+
>>> args = "that is something great"
+>>> args.split(" ", 1) # one split, that is a list with two elements (before, after)
+
+
+
[‘that’, ‘is something great’]
+
+
+
+
+
Read this example as many times as needed to understand it. The second argument we give to
+str.split is not the length of the list that should be returned, but the number of times we have
+to split. Therefore, we specify 1 here, but we get a list of two elements (before the separator,
+after the separator).
+
+
What will happen if Python can’t split the number of times we ask?
+
+
It won’t:
+
>>> args = "whatever"
+>>> args.split(" ", 1) # there isn't even a space here...
+['whatever']
+>>>
+
+
+
This is one moment I would have hoped for an exception and didn’t get one. But there’s another way
+which will raise an exception if there is an error: variable unpacking.
+
We won’t talk about this feature in details here. It would be complicated. But the code is really
+straightforward to use. Let’s take our example of the roll command but let’s add a first argument:
+the number of dice to roll.
+
fromrandomimportrandint
+
+fromevenniaimportCommand,InterruptCommand
+
+classCmdRoll(Command):
+
+ """
+ Play random, enter a number and try your luck.
+
+ Specify two numbers separated by a space. The first number is the
+ number of dice to roll (1, 2, 3) and the second is the expected sum
+ of the roll.
+
+ Usage:
+ roll <dice> <number>
+
+ For instance, to roll two 6-figure dice, enter 2 as first argument.
+ If you think the sum of these two dice roll will be 10, you could enter:
+
+ roll 2 10
+
+ """
+
+ key="roll"
+
+ defparse(self):
+ """Split the arguments and convert them."""
+ args=self.args.lstrip()
+
+ # Split: we expect two arguments separated by a space
+ try:
+ number,guess=args.split(" ",1)
+ exceptValueError:
+ self.msg("Invalid usage. Enter two numbers separated by a space.")
+ raiseInterruptCommand
+
+ # Convert the entered number (first argument)
+ try:
+ self.number=int(number)
+ ifself.number<=0:
+ raiseValueError
+ exceptValueError:
+ self.msg(f"{number} is not a valid number of dice.")
+ raiseInterruptCommand
+
+ # Convert the entered guess (second argument)
+ try:
+ self.guess=int(guess)
+ ifnot1<=self.guess<=self.number*6:
+ raiseValueError
+ exceptValueError:
+ self.msg(f"{self.guess} is not a valid guess.")
+ raiseInterruptCommand
+
+ deffunc(self):
+ # Roll a random die X times (X being self.number)
+ figure=0
+ for_inrange(self.number):
+ figure+=randint(1,6)
+
+ self.msg(f"You roll {self.number} dice and obtain the sum {figure}.")
+
+ ifself.guess==figure:
+ self.msg(f"You played {self.guess}, you have won!")
+ else:
+ self.msg(f"You played {self.guess}, you have lost.")
+
+
+
The beginning of the parse() method is what interests us most:
+
try:
+ number,guess=args.split(" ",1)
+exceptValueError:
+ self.msg("Invalid usage. Enter two numbers separated by a space.")
+ raiseInterruptCommand
+
+
+
We split the argument using str.split but we capture the result in two variables. Python is smart
+enough to know that we want what’s left of the space in the first variable, what’s right of the
+space in the second variable. If there is not even a space in the string, Python will raise a
+ValueError exception.
+
This code is much easier to read than browsing through the returned strings of str.split. We can
+convert both variables the way we did previously. Actually there are not so many changes in this
+version and the previous one, most of it is due to name changes for clarity.
+
+
Splitting a string with a maximum of splits is a common occurrence while parsing command
+arguments. You can also see the str.rspli8t method that does the same thing but from the right of
+the string. Therefore, it will attempt to find delimiters at the end of the string and work toward
+the beginning of it.
+
+
We have used a space as a delimiter. This is absolutely not necessary. You might remember that
+most default Evennia commands can take an = sign as a delimiter. Now you know how to parse them
+as well:
+
>>> cmd_key = "tel"
+>>> cmd_args = "book = chest"
+>>> left, right = cmd_args.split("=") # mighht raise ValueError!
+>>> left
+'book '
+>>> right
+' chest'
+>>>
+
Sometimes, you’ll come across commands that have optional arguments. These arguments are not
+necessary but they can be set if more information is needed. I will not provide the entire command
+code here but just enough code to show the mechanism in Python:
+
Again, we’ll use str.split, knowing that we might not have any delimiter at all. For instance,
+the player could enter the “tel” command like this:
+
> tel book
+> tell book = chest
+
+
+
The equal sign is optional along with whatever is specified after it. A possible solution in our
+parse method would be:
This code would place everything the user entered in obj if she didn’t specify any equal sign.
+Otherwise, what’s before the equal sign will go in obj, what’s after the equal sign will go in
+destination. This makes for quick testing after that, more robust code with less conditions that
+might too easily break your code if you’re not careful.
+
+
Again, here we specified a maximum numbers of splits. If the users enters:
+
+
> tel book = chest = chair
+
+
+
Then destination will contain: "chest=chair". This is often desired, but it’s up to you to
+set parsing however you like.
After this quick tour of some str methods, we’ll take a look at some Evennia-specific features
+that you won’t find in standard Python.
+
One very common task is to convert a str into an Evennia object. Take the previous example:
+having "book" in a variable is great, but we would prefer to know what the user is talking
+about… what is this "book"?
+
To get an object from a string, we perform an Evennia search. Evennia provides a search method on
+all typeclassed objects (you will most likely use the one on characters or accounts). This method
+supports a very wide array of arguments and has its own tutorial.
+Some examples of useful cases follow:
When an account or a character enters a command, the account or character is found in the caller
+attribute. Therefore, self.caller will contain an account or a character (or a session if that’s
+a session command, though that’s not as frequent). The search method will be available on this
+caller.
+
Let’s take the same example of our little “tel” command. The user can specify an object as
+argument:
+
defparse(self):
+ name=self.args.lstrip()
+
+
+
We then need to “convert” this string into an Evennia object. The Evennia object will be searched
+in the caller’s location and its contents by default (that is to say, if the command has been
+entered by a character, it will search the object in the character’s room and the character’s
+inventory).
We specify only one argument to the search method here: the string to search. If Evennia finds a
+match, it will return it and we keep it in the obj attribute. If it can’t find anything, it will
+return None so we need to check for that:
+
defparse(self):
+ name=self.args.lstrip()
+
+ self.obj=self.caller.search(name)
+ ifself.objisNone:
+ # A proper error message has already been sent to the caller
+ raiseInterruptCommand
+
+
+
That’s it. After this condition, you know that whatever is in self.obj is a valid Evennia object
+(another character, an object, an exit…).
By default, Evennia will handle the case when more than one match is found in the search. The user
+will be asked to narrow down and re-enter the command. You can, however, ask to be returned the
+list of matches and handle this list yourself:
+
defparse(self):
+ name=self.args.lstrip()
+
+ objs=self.caller.search(name,quiet=True)
+ ifnotobjs:
+ # This is an empty list, so no match
+ self.msg(f"No {name!r} was found.")
+ raiseInterruptCommand
+
+ self.obj=objs[0]# Take the first match even if there are several
+
+
+
All we have changed to obtain a list is a keyword argument in the search method: quiet. If set
+to True, then errors are ignored and a list is always returned, so we need to handle it as such.
+Notice in this example, self.obj will contain a valid object too, but if several matches are
+found, self.obj will contain the first one, even if more matches are available.
By default, Evennia will perform a local search, that is, a search limited by the location in which
+the caller is. If you want to perform a global search (search in the entire database), just set the
+global_search keyword argument to True:
Parsing command arguments is vital for most game designers. If you design “intelligent” commands,
+users should be able to guess how to use them without reading the help, or with a very quick peek at
+said help. Good commands are intuitive to users. Better commands do what they’re told to do. For
+game designers working on MUDs, commands are the main entry point for users into your game. This is
+no trivial. If commands execute correctly (if their argument is parsed, if they don’t behave in
+unexpected ways and report back the right errors), you will have happier players that might stay
+longer on your game. I hope this tutorial gave you some pointers on ways to improve your command
+parsing. There are, of course, other ways you will discover, or ways you are already using in your
+code.
Evennia consists of two processes, known as Portal and Server. They can be controlled from
+inside the game or from the command line as described here.
+
If you are new to the concept, the main purpose of separating the two is to have accounts connect to
+the Portal but keep the MUD running on the Server. This way one can restart/reload the game (the
+Server part) without Accounts getting disconnected.
+
+
The Server and Portal are glued together via an AMP (Asynchronous Messaging Protocol) connection.
+This allows the two programs to communicate seamlessly.
Sometimes it can be useful to try to determine just how efficient a particular piece of code is, or
+to figure out if one could speed up things more than they are. There are many ways to test the
+performance of Python and the running server.
+
Before digging into this section, remember Donald Knuth’s words of
+wisdom:
+
+
[…]about 97% of the time: Premature optimization is the root of all evil.
+
+
That is, don’t start to try to optimize your code until you have actually identified a need to do
+so. This means your code must actually be working before you start to consider optimization.
+Optimization will also often make your code more complex and harder to read. Consider readability
+and maintainability and you may find that a small gain in speed is just not worth it.
Python’s timeit module is very good for testing small things. For example, in order to test if it
+is faster to use a for loop or a list comprehension you could use the following code:
+
importtimeit
+ # Time to do 1000000 for loops
+ timeit.timeit("for i in range(100):\n a.append(i)",setup="a = []")
+ <<<10.70982813835144
+ # Time to do 1000000 list comprehensions
+ timeit.timeit("a = [i for i in range(100)]")
+ <<<5.358283996582031
+
+
+
The setup keyword is used to set up things that should not be included in the time measurement,
+like a=[] in the first call.
+
By default the timeit function will re-run the given test 1000000 times and returns the total
+time to do so (so not the average per test). A hint is to not use this default for testing
+something that includes database writes - for that you may want to use a lower number of repeats
+(say 100 or 1000) using the number=100 keyword.
Python comes with its own profiler, named cProfile (this is for cPython, no tests have been done
+with pypy at this point). Due to the way Evennia’s processes are handled, there is no point in
+using the normal way to start the profiler (python-mcProfileevennia.py). Instead you start the
+profiler through the launcher:
+
evennia --profiler start
+
+
+
This will start Evennia with the Server component running (in daemon mode) under cProfile. You could
+instead try --profile with the portal argument to profile the Portal (you would then need to
+start the Server separately).
+
Please note that while the profiler is running, your process will use a lot more memory than usual.
+Memory usage is even likely to climb over time. So don’t leave it running perpetually but monitor it
+carefully (for example using the top command on Linux or the Task Manager’s memory display on
+Windows).
+
Once you have run the server for a while, you need to stop it so the profiler can give its report.
+Do not kill the program from your task manager or by sending it a kill signal - this will most
+likely also mess with the profiler. Instead either use evennia.pystop or (which may be even
+better), use @shutdown from inside the game.
+
Once the server has fully shut down (this may be a lot slower than usual) you will find that
+profiler has created a new file mygame/server/logs/server.prof.
The server.prof file is a binary file. There are many ways to analyze and display its contents,
+all of which has only been tested in Linux (If you are a Windows/Mac user, let us know what works).
+
We recommend the
+Runsnake visualizer to see the processor usage
+of different processes in a graphical form. For more detailed listing of usage time, you can use
+KCachegrind. To make KCachegrind work with
+Python profiles you also need the wrapper script
+pyprof2calltree. You can get pyprof2calltree via
+pip whereas KCacheGrind is something you need to get via your package manager or their homepage.
+
How to analyze and interpret profiling data is not a trivial issue and depends on what you are
+profiling for. Evennia being an asynchronous server can also confuse profiling. Ask on the mailing
+list if you need help and be ready to be able to supply your server.prof file for comparison,
+along with the exact conditions under which it was obtained.
It is difficult to test “actual” game performance without having players in your game. For this
+reason Evennia comes with the Dummyrunner system. The Dummyrunner is a stress-testing system: a
+separate program that logs into your game with simulated players (aka “bots” or “dummies”). Once
+connected these dummies will semi-randomly perform various tasks from a list of possible actions.
+Use Ctrl-C to stop the Dummyrunner.
+
+
Warning: You should not run the Dummyrunner on a production database. It will spawn many objects
+and also needs to run with general permissions.
+
+
To launch the Dummyrunner, first start your server normally (with or without profiling, as above).
+Then start a new terminal/console window and active your virtualenv there too. In the new terminal,
+try to connect 10 dummy players:
+
evennia --dummyrunner 10
+
+
+
The first time you do this you will most likely get a warning from Dummyrunner. It will tell you to
+copy an import string to the end of your settings file. Quit the Dummyrunner (Ctrl-C) and follow
+the instructions. Restart Evennia and try evennia--dummyrunner10 again. Make sure to remove that
+extra settings line when running a public server.
+
The actions perform by the dummies is controlled by a settings file. The default Dummyrunner
+settings file is evennia/server/server/profiling/dummyrunner_settings.py but you shouldn’t modify
+this directly. Rather create/copy the default file to mygame/server/conf/ and modify it there. To
+make sure to use your file over the default, add the following line to your settings file:
Hint: Don’t start with too many dummies. The Dummyrunner defaults to taxing the server much more
+intensely than an equal number of human players. A good dummy number to start with is 10-100.
+
+
Once you have the dummyrunner running, stop it with Ctrl-C.
+
Generally, the dummyrunner system makes for a decent test of general performance; but it is of
+course hard to actually mimic human user behavior. For this, actual real-game testing is required.
This is the first part of our beginner’s guide to the basics of using Python with Evennia. It’s
+aimed at you with limited or no programming/Python experience. But also if you are an experienced
+programmer new to Evennia or Python you might still pick up a thing or two. It is by necessity brief
+and low on detail. There are countless Python guides and tutorials, books and videos out there for
+learning more in-depth - use them!
This quickstart assumes you have gotten Evennia started. You should make sure
+that you are able to see the output from the server in the console from which you started it. Log
+into the game either with a mud client on localhost:4000 or by pointing a web browser to
+localhost:4001/webclient. Log in as your superuser (the user you created during install).
+
Below, lines starting with a single > means command input.
The py (or ! which is an alias) command allows you as a superuser to run raw Python from in-
+game. From the game’s input line, enter the following:
+
> py print("Hello World!")
+
+
+
You will see
+
> print("Hello world!")
+Hello World
+
+
+
To understand what is going on: some extra info: The print(...)function is the basic, in-built
+way to output text in Python. The quotes "..." means you are inputing a string (i.e. text). You
+could also have used single-quotes '...', Python accepts both.
+
The first return line (with >>>) is just py echoing what you input (we won’t include that in the
+examples henceforth).
+
+
Note: You may sometimes see people/docs refer to @py or other commands starting with @.
+Evennia ignores @ by default, so @py is the exact same thing as py.
+
+
The print command is a standard Python structure. We can use that here in the py command, and
+it’s great for debugging and quick testing. But if you need to send a text to an actual player,
+print won’t do, because it doesn’t know who to send to. Try this:
+
> py me.msg("Hello world!")
+Hello world!
+
+
+
This looks the same as the print result, but we are now actually messaging a specific object,
+me. The me is something uniquely available in the py command (we could also use self, it’s
+an alias). It represents “us”, the ones calling the py command. The me is an example of an
+Object instance. Objects are fundamental in Python and Evennia. The me object not only
+represents the character we play in the game, it also contains a lot of useful resources for doing
+things with that Object. One such resource is msg. msg works like print except it sends the
+text to the object it is attached to. So if we, for example, had an object you, doing
+you.msg(...) would send a message to the object you.
+
You access an Object’s resources by using the full-stop character .. So self.msg accesses the
+msg resource and then we call it like we did print, with our “Hello World!” greeting in
+parentheses.
+
+
Important: something like print(...) we refer to as a function, while msg(...) which sits on
+an object is called a method.
+
+
For now, print and me.msg behaves the same, just remember that you’re going to mostly be using
+the latter in the future. Try printing other things. Also try to include |r at the start of your
+string to make the output red in-game. Use color to learn more color tags.
Keep your game running, then open a text editor of your choice. If your game folder is called
+mygame, create a new text file test.py in the subfolder mygame/world. This is how the file
+structure should look:
+
mygame/
+ world/
+ test.py
+
+
+
For now, only add one line to test.py:
+
print("Hello World!")
+
+
+
Don’t forget to save the file. A file with the ending .py is referred to as a Python module. To
+use this in-game we have to import it. Try this:
+
>@pyimportworld.test
+HelloWorld
+
+
+
If you make some error (we’ll cover how to handle errors below) you may need to run the @reload
+command for your changes to take effect.
+
So importing world.test actually means importing world/test.py. Think of the period . as
+replacing / (or \ for Windows) in your path. The .py ending of test.py is also never
+included in this “Python-path”, but only files with that ending can be imported this way. Where is
+mygame in that Python-path? The answer is that Evennia has already told Python that your mygame
+folder is a good place to look for imports. So we don’t include mygame in the path - Evennia
+handles this for us.
+
When you import the module, the top “level” of it will execute. In this case, it will immediately
+print “Hello World”.
+
+
If you look in the folder you’ll also often find new files ending with .pyc. These are compiled
+Python binaries that Python auto-creates when running code. Just ignore them, you should never edit
+those anyway.
+
+
Now try to run this a second time:
+
>pyimportworld.test
+
+
+
You will not see any output this second time or any subsequent times! This is not a bug. Rather
+it is because Python is being clever - it stores all imported modules and to be efficient it will
+avoid importing them more than once. So your print will only run the first time, when the module
+is first imported. To see it again you need to @reload first, so Python forgets about the module
+and has to import it again.
+
We’ll get back to importing code in the second part of this tutorial. For now, let’s press on.
Next, erase the single print statement you had in test.py and replace it with this instead:
+
me.msg("Hello World!")
+
+
+
As you recall we used this from py earlier - it echoed “Hello World!” in-game.
+Save your file and reload your server - this makes sure Evennia sees the new version of your code.
+Try to import it from py in the same way as earlier:
This is called a traceback. Python’s errors are very friendly and will most of the time tell you
+exactly what and where things are wrong. It’s important that you learn to parse tracebacks so you
+can fix your code. Let’s look at this one. A traceback is to be read from the bottom up. The last
+line is the error Python balked at, while the two lines above it details exactly where that error
+was encountered.
+
+
An error of type NameError is the problem …
+
… more specifically it is due to the variable me not being defined.
+
This happened on the line me.msg("Helloworld!") …
+
… which is on line 1 of the file ./world/test.py.
+
+
In our case the traceback is short. There may be many more lines above it, tracking just how
+different modules called each other until it got to the faulty line. That can sometimes be useful
+information, but reading from the bottom is always a good start.
+
The NameError we see here is due to a module being its own isolated thing. It knows nothing about
+the environment into which it is imported. It knew what print is because that is a special
+reserved Python keyword. But me is not such a
+reserved word. As far as the module is concerned me is just there out of nowhere. Hence the
+NameError.
Let’s see if we can resolve that NameError from the previous section. We know that me is defined
+at the time we use the @py command because if we do pyme.msg("HelloWorld!") directly in-game
+it works fine. What if we could send that me to the test.py module so it knows what it is? One
+way to do this is with a function.
+
Change your mygame/world/test.py file to look like this:
+
defhello_world(who):
+ who.msg("Hello World!")
+
+
+
Now that we are moving onto multi-line Python code, there are some important things to remember:
+
+
Capitalization matters in Python. It must be def and not DEF, who is not the same as Who
+etc.
+
Indentation matters in Python. The second line must be indented or it’s not valid code. You should
+also use a consistent indentation length. We strongly recommend that you set up your editor to
+always indent 4 spaces (not a single tab-character) when you press the TAB key - it will make
+your life a lot easier.
+
def is short for “define” and defines a function (or a method, if sitting on an object).
+This is a reserved Python keyword; try not to use
+these words anywhere else.
who is what we call the argument to our function. Arguments are variables we pass to the
+function. We could have named it anything and we could also have multiple arguments separated by
+commas. What who is depends on what we pass to this function when we call it later (hint: we’ll
+pass me to it).
+
The colon (:) at the end of the first line indicates that the header of the function is
+complete.
+
The indentation marks the beginning of the actual operating code of the function (the function’s
+body). If we wanted more lines to belong to this function those lines would all have to have to
+start at this indentation level.
+
In the function body we take the who argument and treat it as we would have treated me earlier
+
we expect it to have a .msg method we can use to send “Hello World” to.
+
+
First, reload your game to make it aware of the updated Python module. Now we have defined our
+first function, let’s use it.
+
> reload
+> py import world.test
+
+
+
Nothing happened! That is because the function in our module won’t do anything just by importing it.
+It will only act when we call it. We will need to enter the module we just imported and do so.
There is our “Hello World”! The ; is the way to put multiple Python-statements on one line.
+
+
Some MUD clients use ; for their own purposes to separate client-inputs. If so you’ll get a
+NameError stating that world is not defined. Check so you understand why this is! Change the use
+of ; in your client or use the Evennia web client if this is a problem.
+
+
In the second statement we access the module path we imported (world.test) and reach for the
+hello_world function within. We call the function with me, which becomes the who variable we
+use inside the hello_function.
+
+
As an exercise, try to pass something else into hello_world. Try for example to pass who as
+the number 5 or the simple string "foo". You’ll get errors that they don’t have the attribute
+msg. As we’ve seen, medoes make msg available which is why it works (you’ll learn more
+about Objects like me in the next part of this tutorial). If you are familiar with other
+programming languages you may be tempted to start validatingwho to make sure it works as
+expected. This is usually not recommended in Python which suggests it’s better to
+handle the error if it happens rather than to make
+a lot of code to prevent it from happening. See also duck
+typing.
As you start to explore Evennia, it’s important that you know where to look when things go wrong.
+While using the friendly py command you’ll see errors directly in-game. But if something goes
+wrong in your code while the game runs, you must know where to find the log.
+
Open a terminal (or go back to the terminal you started Evennia in), make sure your virtualenv is
+active and that you are standing in your game directory (the one created with evennia--init
+during installation). Enter
+
evennia--log
+
+
+
(or evennia-l)
+
This will show the log. New entries will show up in real time. Whenever you want to leave the log,
+enter Ctrl-C or Cmd-C depending on your system. As a game dev it is important to look at the
+log output when working in Evennia - many errors will only appear with full details here. You may
+sometimes have to scroll up in the history if you miss it.
+
This tutorial is continued in Part 2, where we’ll start learning
+about objects and to explore the Evennia library.
In the first part of this Python-for-Evennia basic tutorial we learned
+how to run some simple Python code from inside the game. We also made our first new module
+containing a function that we called. Now we’re going to start exploring the very important
+subject of objects.
In the first part of the tutorial we did things like
+
> py me.msg("Hello World!")
+
+
+
To learn about functions and imports we also passed that me on to a function hello_world in
+another module.
+
Let’s learn some more about this me thing we are passing around all over the place. In the
+following we assume that we named our superuser Character “Christine”.
+
> py me
+Christine
+> py me.key
+Christine
+
+
+
These returns look the same at first glance, but not if we examine them more closely:
Note: In some MU clients, such as Mudlet and MUSHclient simply returning type(me), you may not
+see the proper return from the above commands. This is likely due to the HTML-like tags <...>,
+being swallowed by the client.
+
+
The type function is, like print, another in-built function in Python. It
+tells us that we (me) are of the classtypeclasses.characters.Character.
+Meanwhile me.key is a property on us, a string. It holds the name of this
+object.
+
+
When you do pyme, the me is defined in such a way that it will use its .key property to
+represent itself. That is why the result is the same as when doing pyme.key. Also, remember that
+as noted in the first part of the tutorial, the me is not a reserved Python word; it was just
+defined by the Evennia developers as a convenient short-hand when creating the py command. So
+don’t expect me to be available elsewhere.
+
+
A class is like a “factory” or blueprint. From a class you then create individual instances. So
+if class isDog, an instance of Dog might be fido. Our in-game persona is of a class
+Character. The superuser christine is an instance of the Character class (an instance is
+also often referred to as an object). This is an important concept in object oriented
+programming. You are wise to familiarize yourself with it a little.
+
+
In other terms:
+
+
class: A description of a thing, all the methods (code) and data (information)
+
object: A thing, defined as an instance of a class.
+
+
So in “Fido is a Dog”, “Fido” is an object–a unique thing–and “Dog” is a class. Coders would
+also say, “Fido is an instance of Dog”. There can be other dogs too, such as Butch and Fifi. They,
+too, would be instances of Dog.
+
As another example: “Christine is a Character”, or “Christine is an instance of
+typeclasses.characters.Character”. To start, all characters will be instances of
+typeclass.characters.Character.
+
You’ll be writing your own class soon! The important thing to know here is how classes and objects
+relate.
+
+
The string 'typeclasses.characters.Character' we got from the type() function is not arbitrary.
+You’ll recognize this from when we importedworld.test in part one. This is a path exactly
+describing where to find the python code describing this class. Python treats source code files on
+your hard drive (known as modules) as well as folders (known as packages) as objects that you
+access with the . operator. It starts looking at a place that Evennia has set up for you - namely
+the root of your own game directory.
+
Open and look at your game folder (named mygame if you exactly followed the Getting Started
+instructions) in a file editor or in a new terminal/console. Locate the file
+mygame/typeclasses/characters.py
+
mygame/
+ typeclasses
+ characters.py
+
+
+
This represents the first part of the python path - typeclasses.characters (the .py file ending
+is never included in the python path). The last bit, .Character is the actual class name inside
+the characters.py module. Open that file in a text editor and you will see something like this:
There is Character, the last part of the path. Note how empty this file is. At first glance one
+would think a Character had no functionality at all. But from what we have used already we know it
+has at least the key property and the method msg! Where is the code? The answer is that this
+‘emptiness’ is an illusion caused by something called inheritance. Read on.
+
Firstly, in the same way as the little hello.py we did in the first part of the tutorial, this is
+an example of full, multi-line Python code. Those triple-quoted strings are used for strings that
+have line breaks in them. When they appear on their own like this, at the top of a python module,
+class or similar they are called doc strings. Doc strings are read by Python and is used for
+producing online help about the function/method/class/module. By contrast, a line starting with #
+is a comment. It is ignored completely by Python and is only useful to help guide a human to
+understand the code.
+
The line
+
classCharacter(DefaultCharacter):
+
+
+
means that the class Character is a child of the class DefaultCharacter. This is called
+inheritance and is another fundamental concept. The answer to the question “where is the code?” is
+that the code is inherited from its parent, DefaultCharacter. And that in turn may inherit code
+from its parent(s) and so on. Since our child, Character is empty, its functionality is exactly
+identical to that of its parent. The moment we add new things to Character, these will take
+precedence. And if we add something that already existed in the parent, our child-version will
+override the version in the parent. This is very practical: It means that we can let the parent do
+the heavy lifting and only tweak the things we want to change. It also means that we could easily
+have many different Character classes, all inheriting from DefaultCharacter but changing different
+things. And those can in turn also have children …
+
Let’s go on an expedition up the inheritance tree.
Let’s figure out how to tweak Character. Right now we don’t know much about DefaultCharacter
+though. Without knowing that we won’t know what to override. At the top of the file you find
+
fromevenniaimportDefaultCharacter
+
+
+
This is an import statement again, but on a different form to what we’ve seen before. from...import... is very commonly used and allows you to precisely dip into a module to extract just the
+component you need to use. In this case we head into the evennia package to get
+DefaultCharacter.
+
Where is evennia? To find it you need to go to the evennia folder (repository) you originally
+cloned from us. If you open it, this is how it looks:
There are lots of things in there. There are some docs but most of those have to do with the
+distribution of Evennia and does not concern us right now. The evennia subfolder is what we are
+looking for. This is what you are accessing when you do fromevenniaimport.... It’s set up by
+Evennia as a good place to find modules when the server starts. The exact layout of the Evennia
+library is covered by our directory overview. You can
+also explore it online on github.
+
The structure of the library directly reflects how you import from it.
+
+
To, for example, import the text justify function from
+evennia/utils/utils.py you would do fromevennia.utils.utilsimportjustify. In your code you
+could then just call justify(...) to access its functionality.
+
You could also do fromevennia.utilsimportutils. In code you would then have to write
+utils.justify(...). This is practical if want a lot of stuff from that utils.py module and don’t
+want to import each component separately.
+
You could also do importevennia. You would then have to enter the full
+evennia.utils.utils.justify(...) every time you use it. Using from to only import the things you
+need is usually easier and more readable.
+
See this overview about the different ways to
+import in Python.
+
+
Now, remember that our characters.py module did fromevenniaimportDefaultCharacter. But if we
+look at the contents of the evennia folder, there is no DefaultCharacter anywhere! This is
+because Evennia gives a large number of optional “shortcuts”, known as the “flat” API. The intention is to make it easier to remember where to find stuff. The flat API is defined in
+that weirdly named __init__.py file. This file just basically imports useful things from all over
+Evennia so you can more easily find them in one place.
+
We could just look at the documenation to find out where we can look
+at our DefaultCharacter parent. But for practice, let’s figure it out. Here is where
+DefaultCharacteris imported from inside __init__.py:
+
from.objects.objectsimportDefaultCharacter
+
+
+
The period at the start means that it imports beginning from the same location this module sits(i.e.
+the evennia folder). The full python-path accessible from the outside is thus
+evennia.objects.objects.DefaultCharacter. So to import this into our game it’d be perfectly valid
+to do
is the same thing, just a little easier to remember.
+
+
To access the shortcuts of the flat API you must use fromevenniaimport.... Using something like importevennia.DefaultCharacter will not work.
+See more about the Flat API here.
In the previous section we traced the parent of our Character class to be
+DefaultCharacter in
+evennia/objects/objects.py.
+Open that file and locate the DefaultCharacter class. It’s quite a bit down
+in this module so you might want to search using your editor’s (or browser’s)
+search function. Once you find it, you’ll find that the class starts like this:
+
+classDefaultCharacter(DefaultObject):
+ """
+ This implements an Object puppeted by a Session - that is, a character
+ avatar controlled by an account.
+ """
+
+ defbasetype_setup(self):
+ """
+ Setup character-specific security.
+ You should normally not need to overload this, but if you do,
+ make sure to reproduce at least the two last commands in this
+ method (unless you want to fundamentally change how a
+ Character object works).
+ """
+ super().basetype_setup()
+ self.locks.add(";".join(["get:false()",# noone can pick up the character
+ "call:false()"]))# no commands can be called on character from
+outside
+ # add the default cmdset
+ self.cmdset.add_default(settings.CMDSET_CHARACTER,permanent=True)
+
+ defat_after_move(self,source_location,**kwargs):
+ """
+ We make sure to look around after a move.
+ """
+ ifself.location.access(self,"view"):
+ self.msg(self.at_look(self.location))
+
+ defat_pre_puppet(self,account,session=None,**kwargs):
+ """
+ Return the character from storage in None location in `at_post_unpuppet`.
+ """
+
+ # ...
+
+
+
+
… And so on (you can see the full class online here). Here we
+have functional code! These methods may not be directly visible in Character back in our game dir,
+but they are still available since Character is a child of DefaultCharacter above. Here is a
+brief summary of the methods we find in DefaultCharacter (follow in the code to see if you can see
+roughly where things happen)::
+
+
basetype_setup is called by Evennia only once, when a Character is first created. In the
+DefaultCharacter class it sets some particular Locks so that people can’t pick up and
+puppet Characters just like that. It also adds the Character Cmdset so that
+Characters always can accept command-input (this should usually not be modified - the normal hook to
+override is at_object_creation, which is called after basetype_setup (it’s in the parent)).
+
at_after_move makes it so that every time the Character moves, the look command is
+automatically fired (this would not make sense for just any regular Object).
+
at_pre_puppet is called when an Account begins to puppet this Character. When not puppeted, the
+Character is hidden away to a None location. This brings it back to the location it was in before.
+Without this, “headless” Characters would remain in the game world just standing around.
+
at_post_puppet is called when puppeting is complete. It echoes a message to the room that his
+Character has now connected.
+
at_post_unpuppet is called once stopping puppeting of the Character. This hides away the
+Character to a None location again.
+
There are also some utility properties which makes it easier to get some time stamps from the
+Character.
+
+
Reading the class we notice another thing:
+
classDefaultCharacter(DefaultObject):
+ # ...
+
+
+
This means that DefaultCharacter is in itself a child of something called DefaultObject! Let’s
+see what this parent class provides. It’s in the same module as DefaultCharacter, you just need to
+scroll up near the top:
This is a really big class where the bulk of code defining an in-game object resides. It consists of
+a large number of methods, all of which thus also becomes available on the DefaultCharacter class
+below and by extension in your Character class over in your game dir. In this class you can for
+example find the msg method we have been using before.
+
+
You should probably not expect to understand all details yet, but as an exercise, find and read
+the doc string of msg.
+
+
+
As seen, DefaultObject actually has multiple parents. In one of those the basic key property
+is defined, but we won’t travel further up the inheritance tree in this tutorial. If you are
+interested to see them, you can find TypeclassBase in evennia/typeclasses/models.py and ObjectDB in evennia/obj
+ects/models.py. We
+will also not go into the details of Multiple Inheritance or
+Metaclasses here. The general rule
+is that if you realize that you need these features, you already know enough to use them.
+
+
Remember the at_pre_puppet method we looked at in DefaultCharacter? If you look at the
+at_pre_puppet hook as defined in DefaultObject you’ll find it to be completely empty (just a
+pass). So if you puppet a regular object it won’t be hiding/retrieving the object when you
+unpuppet it. The DefaultCharacter class overrides its parent’s functionality with a version of
+its own. And since it’s DefaultCharacter that our Character class inherits back in our game dir,
+it’s that version of at_pre_puppet we’ll get. Anything not explicitly overridden will be passed
+down as-is.
+
While it’s useful to read the code, we should never actually modify anything inside the evennia
+folder. Only time you would want that is if you are planning to release a bug fix or new feature for
+Evennia itself. Instead you override the default functionality inside your game dir.
+
So to conclude our little foray into classes, objects and inheritance, locate the simple little
+at_before_say method in the DefaultObject class:
If you read the doc string you’ll find that this can be used to modify the output of say before it
+goes out. You can think of it like this: Evennia knows the name of this method, and when someone
+speaks, Evennia will make sure to redirect the outgoing message through this method. It makes it
+ripe for us to replace with a version of our own.
+
+
In the Evennia documentation you may sometimes see the term hook used for a method explicitly
+meant to be overridden like this.
+
+
As you can see, the first argument to at_before_say is self. In Python, the first argument of a
+method is always a back-reference to the object instance on which the method is defined. By
+convention this argument is always called self but it could in principle be named anything. The
+self is very useful. If you wanted to, say, send a message to the same object from inside
+at_before_say, you would do self.msg(...).
+
What can trip up newcomers is that you don’t include self when you call the method. Try:
Note that we don’t send self but only the message argument. Python will automatically add self
+for us. In this case, self will become equal to the Character instance me.
+
By default the at_before_say method doesn’t do anything. It just takes the message input and
+returns it just the way it was (the return is another reserved Python word).
+
+
We won’t go into **kwargs here, but it (and its sibling *args) is also important to
+understand, extra reading is here for **kwargs.
+
+
Now, open your game folder and edit mygame/typeclasses/characters.py. Locate your Character
+class and modify it as such:
So we add our own version of at_before_say, duplicating the def line from the parent but putting
+new code in it. All we do in this tutorial is to add an ellipsis (...) to the message as it passes
+through the method.
+
Note that f in front of the string, it means we turned the string into a ‘formatted string’. We
+can now easily inject stuff directly into the string by wrapping them in curly brackets {}. In
+this example, we put the incoming message into the string, followed by an ellipsis. This is only
+one way to format a string. Python has very powerful string formatting and
+you are wise to learn it well, considering your game will be mainly text-based.
+
+
You could also copy & paste the relevant method from DefaultObject here to get the full doc
+string. For more complex methods, or if you only want to change some small part of the default
+behavior, copy & pasting will eliminate the need to constantly look up the original method and keep
+you sane.
+
+
In-game, now try
+
> @reload
+> say Hello
+You say, "Hello ..."
+
+
+
An ellipsis ... is added to what you said! This is a silly example but you have just made your
+first code change to core functionality - without touching any of Evennia’s original code! We just
+plugged in our own version of the at_before_say method and it replaced the default one. Evennia
+happily redirected the message through our version and we got a different output.
+
+
For sane overriding of parent methods you should also be aware of Python’s
+super, which allows you to call the
+methods defined on a parent in your child class.
Now on to some generally useful tools as you continue learning Python and Evennia. We have so far
+explored using py and have inserted Python code directly in-game. We have also modified Evennia’s
+behavior by overriding default functionality with our own. There is a third way to conveniently
+explore Evennia and Python - the Evennia shell.
+
Outside of your game, cd to your mygame folder and make sure any needed virtualenv is running.
+Next:
+
> pip install ipython # only needed once
+
+
+
The IPython program is just a nicer interface to the
+Python interpreter - you only need to install it once, after which Evennia will use it
+automatically.
+
> evennia shell
+
+
+
If you did this call from your game dir you will now be in a Python prompt managed by the IPython
+program.
+
IPython ...
+...
+In [1]: IPython has some very nice ways to explore what Evennia has to offer.
+
+> import evennia
+> evennia.<TAB>
+
+
+
That is, write evennia. and press the Tab key. You will be presented with a list of all available
+resources in the Evennia Flat API. We looked at the __init__.py file in the evennia folder
+earlier, so some of what you see should be familiar. From the IPython prompt, do:
+
> from evennia import DefaultCharacter
+> DefaultCharacter.at_before_say?
+
+
+
Don’t forget that you can use <TAB> to auto-complete code as you write. Appending a single ? to
+the end will show you the doc-string for at_before_say we looked at earlier. Use ?? to get the
+whole source code.
+
Let’s look at our over-ridden version instead. Since we started the evenniashell from our game
+dir we can easily get to our code too:
+
> from typeclasses.characters import Character
+> Character.at_before_say??
+
+
+
This will show us the changed code we just did. Having a window with IPython running is very
+convenient for quickly exploring code without having to go digging through the file structure!
This should give you a running start using Python with Evennia. If you are completely new to
+programming or Python you might want to look at a more formal Python tutorial. You can find links
+and resources on our link page.
Once you have familiarized yourself, or if you prefer to pick Python up as you go, continue to one
+of the beginning-level Evennia tutorials to gradually build up your understanding.
This is a list of various quirks or common stumbling blocks that people often ask about or report
+when using (or trying to use) Evennia. They are not bugs.
+
+
Forgetting to use @reload to see changes to your typeclasses¶
+
Firstly: Reloading the server is a safe and usually quick operation which will not disconnect any
+accounts.
+
New users tend to forget this step. When editing source code (such as when tweaking typeclasses and
+commands or adding new commands to command sets) you need to either use the in-game @reload
+command or, from the command line do pythonevennia.pyreload before you see your changes.
If you use the default login system and are trying to use the Web admin to create a new Player
+account, you need to consider which MULTIACCOUNT_MODE you are in. If you are in
+MULTIACCOUNT_MODE0 or 1, the login system expects each Account to also have a Character
+object named the same as the Account - there is no character creation screen by default. If using
+the normal mud login screen, a Character with the same name is automatically created and connected
+to your Account. From the web interface you must do this manually.
+
So, when creating the Account, make sure to also create the Character from the same form as you
+create the Account from. This should set everything up for you. Otherwise you need to manually set
+the “account” property on the Character and the “character” property on the Account to point to each
+other. You must also set the lockstring of the Character to allow the Account to “puppet” this
+particular character.
+
+
+
Mutable attributes and their connection to the database¶
+
When storing a mutable object (usually a list or a dictionary) in an Attribute
+
object.db.mylist=[1,2,3]
+
+
+
you should know that the connection to the database is retained also if you later extract that
+Attribute into another variable (what is stored and retrieved is actually a PackedList or a
+PackedDict that works just like their namesakes except they save themselves to the database when
+changed). So if you do
+
alist=object.db.mylist
+ alist.append(4)
+
+
+
this updates the database behind the scenes, so both alist and object.db.mylist are now
+[1,2,3,4]
+
If you don’t want this, Evennia provides a way to stably disconnect the mutable from the database by
+use of evennia.utils.dbserialize.deserialize:
The property blist is now [1,2,3,4] whereas object.db.mylist remains unchanged. If you want to
+update the database you’d need to explicitly re-assign the updated data to the mylist Attribute.
When merging command sets it’s important to remember that command objects are identified
+both by key or alias. So if you have a command with a key look and an alias ls, introducing
+another command with a key ls will be assumed by the system to be identical to the first one.
+This usually means merging cmdsets will overload one of them depending on priority. Whereas this is
+logical once you know how command objects are handled, it may be confusing if you are just looking
+at the command strings thinking they are parsed as-is.
A common confusing error for new developers is finding that one or more objects in-game are suddenly
+of the type DefaultObject rather than the typeclass you wanted it to be. This happens when you
+introduce a critical Syntax error to the module holding your custom class. Since such a module is
+not valid Python, Evennia can’t load it at all to get to the typeclasses within. To keep on running,
+Evennia will solve this by printing the full traceback to the terminal/console and temporarily fall
+back to the safe DefaultObject until you fix the problem and reload. Most errors of this kind will
+be caught by any good text editors. Keep an eye on the terminal/console during a reload to catch
+such errors - you may have to scroll up if your window is small.
Python implements a system of magic
+methods, usually
+prefixed and suffixed by double-underscores (__example__) that allow object instances to have
+certain operations performed on them without needing to do things like turn them into strings or
+numbers first– for example, is obj1 greater than or equal to obj2?
+
Neither object is a number, but given obj1.size=="small" and obj2.size=="large", how might
+one compare these two arbitrary English adjective strings to figure out which is greater than the
+other? By defining the __ge__ (greater than or equal to) magic method on the object class in which
+you figure out which word has greater significance, perhaps through use of a mapping table
+({'small':0,'large':10}) or other lookup and comparing the numeric values of each.
+
Evennia extensively makes use of magic methods on typeclasses to do things like initialize objects,
+check object existence or iterate over objects in an inventory or container. If you override or
+interfere with the return values from the methods Evennia expects to be both present and working, it
+can result in very inconsistent and hard-to-diagnose errors.
+
The moral of the story– it can be dangerous to tinker with magic methods on typeclassed objects.
+Try to avoid doing so.
There is currently (Autumn 2017) a bug in the zope.interface installer on some Linux Ubuntu
+distributions (notably Ubuntu 16.04 LTS). Zope is a dependency of Twisted. The error manifests in
+the server not starting with an error that zope.interface is not found even though piplist
+shows it’s installed. The reason is a missing empty __init__.py file at the root of the zope
+package. If the virtualenv is named “evenv” as suggested in the Getting Started
+instructions, use the following command to fix it:
RSS is a format for easily tracking updates on websites. The
+principle is simple - whenever a site is updated, a small text file is updated. An RSS reader can
+then regularly go online, check this file for updates and let the user know what’s new.
+
Evennia allows for connecting any number of RSS feeds to any number of in-game channels. Updates to
+the feed will be conveniently echoed to the channel. There are many potential uses for this: For
+example the MUD might use a separate website to host its forums. Through RSS, the players can then
+be notified when new posts are made. Another example is to let everyone know you updated your dev
+blog. Admins might also want to track the latest Evennia updates through our own RSS feed
+here.
You can connect RSS to any Evennia channel, but for testing, let’s set up a new channel “rss”.
+
@ccreate rss = RSS feeds are echoed to this channel!
+
+
+
Let’s connect Evennia’s code-update feed to this channel. The RSS url for evennia updates is
+https://github.com/evennia/evennia/commits/master.atom, so let’s add that:
That’s it, really. New Evennia updates will now show up as a one-line title and link in the channel.
+Give the @rss2chan command on its own to show all connections. To remove a feed from a channel,
+you specify the connection again (use the command to see it in the list) but add the /delete
+switch:
You can connect any number of RSS feeds to a channel this way. You could also connect them to the
+same channels as IRC to have the feed echo to external chat channels as well.
First, install the docker program so you can run the Evennia container. You can get it freely from
+docker.com. Linux users can likely also get it through their normal
+package manager.
+
To fetch the latest evennia docker image, run:
+
docker pull evennia/evennia
+
+
+
This is a good command to know, it is also how you update to the latest version when we make updates
+in the future. This tracks the master branch of Evennia.
+
+
Note: If you want to experiment with the (unstable) develop branch, use dockerpullevennia/evennia:develop.
+
+
Next cd to a place where your game dir is, or where you want to create it. Then run:
Having run this (see next section for a description of what’s what), you will be at a prompt inside
+the docker container:
+
evennia|docker /usr/src/game $
+
+
+
This is a normal shell prompt. We are in the /usr/src/game location inside the docker container.
+If you had anything in the folder you started from, you should see it here (with ls) since we
+mounted the current directory to /usr/src/game (with -v above). You have the evennia command
+available and can now proceed to create a new game as per the Getting Started
+instructions (you can skip the virtualenv and install ‘globally’ in the container though).
+
You can run Evennia from inside this container if you want to, it’s like you are root in a little
+isolated Linux environment. To exit the container and all processes in there, press Ctrl-D. If you
+created a new game folder, you will find that it has appeared on-disk.
+
+
The game folder or any new files that you created from inside the container will appear as owned
+by root. If you want to edit the files outside of the container you should change the ownership.
+On Linux/Mac you do this with sudochownmyname:myname-Rmygame, where you replace myname with
+your username and mygame with whatever your game folder is named.
dockerrun...evennia/evennia tells us that we want to run a new container based on the
+evennia/evennia docker image. Everything in between are options for this. The evennia/evennia is
+the name of our official docker image on the dockerhub
+repository. If you didn’t do dockerpullevennia/evennia first, the image will be downloaded when running this, otherwise your already
+downloaded version will be used. It contains everything needed to run Evennia.
+
-it has to do with creating an interactive session inside the container we start.
+
--rm will make sure to delete the container when it shuts down. This is nice to keep things tidy
+on your drive.
+
-p4000:4000-p4001:4001-p4002:4002 means that we map ports 4000, 4001 and 4002 from
+inside the docker container to same-numbered ports on our host machine. These are ports for telnet,
+webserver and websockets. This is what allows your Evennia server to be accessed from outside the
+container (such as by your MUD client)!
+
-v$PWD:/usr/src/game mounts the current directory (outside the container) to the path
+/usr/src/gameinside the container. This means that when you edit that path in the container you
+will actually be modifying the “real” place on your hard drive. If you didn’t do this, any changes
+would only exist inside the container and be gone if we create a new one. Note that in linux a
+shortcut for the current directory is $PWD. If you don’t have this for your OS, you can replace it
+with the full path to the current on-disk directory (like C:/Development/evennia/game or wherever
+you want your evennia files to appear).
+
--user$UID:$GID ensures the container’s modifications to $PWD are done with you user and
+group IDs instead of root’s IDs (root is the user running evennia inside the container). This avoids
+having stale .pid files in your filesystem between container reboots which you have to force
+delete with sudormserver/*.pid before each boot.
If you run the docker command given in the previous section from your game dir you can then
+easily start Evennia and have a running server without any further fuss.
+
But apart from ease of install, the primary benefit to running an Evennia-based game in a container
+is to simplify its deployment into a public production environment. Most cloud-based hosting
+providers these days support the ability to run container-based applications. This makes deploying
+or updating your game as simple as building a new container image locally, pushing it to your Docker
+Hub account, and then pulling from Docker Hub into your AWS/Azure/other docker-enabled hosting
+account. The container eliminates the need to install Python, set up a virtualenv, or run pip to
+install dependencies.
For remote or automated deployment you may want to start Evennia immediately as soon as the docker
+container comes up. If you already have a game folder with a database set up you can also start the
+docker container and pass commands directly to it. The command you pass will be the main process to
+run in the container. From your game dir, run for example this command:
This will start Evennia as the foreground process, echoing the log to the terminal. Closing the
+terminal will kill the server. Note that you must use a foreground command like evenniastart--log or evenniaipstart to start the server - otherwise the foreground process will finish
+immediately and the container go down.
You may want to create your own image in order to bake in your gamedir directly into the docker
+container for easy upload and deployment.
+These steps assume that you have created or otherwise obtained a game directory already. First, cd
+to your game dir and create a new empty text file named Dockerfile. Save the following two lines
+into it:
These are instructions for building a new docker image. This one is based on the official
+evennia/evennia image. We add the second line to
+make make sure evennia starts immediately along with the container (so we don’t need to enter it and
+run commands).
+
To build the image:
+
docker build -t mydhaccount/mygame .
+
+
+
(don’t forget the period . at the end, it tells docker to use use the Dockerfile from the
+current location). Here mydhaccount is the name of your dockerhub account. If you don’t have a
+dockerhub account you can build the image locally only (name the container whatever you like in that
+case, like just mygame).
+
Docker images are stored centrally on your computer. You can see which ones you have available
+locally with dockerimages. Once built, you have a couple of options to run your game.
+
If you have a docker-hub account, you can push your (new or updated) image there:
+
docker push myhdaccount/mygame
+
+
+
+
+
Run container from your game image for development¶
+
To run the container based on your game image locally for development, mount the local game
+directory as before:
Evennia will start and you’ll get output in the terminal, perfect for development. You should be
+able to connect to the game with your clients normally.
Each time you rebuild the docker image as per the above instructions, the latest copy of your game
+directory is actually copied inside the image (at /usr/src/game/). If you don’t mount your on-disk
+folder there, the internal one will be used. So for deploying evennia on a server, omit the -v
+option and just give the following command:
Your game will be downloaded from your docker-hub account and a new container will be built using
+the image and started on the server! If your server environment forces you to use different ports,
+you can just map the normal ports differently in the command above.
+
Above we added the -d option, which starts the container in daemon mode - you won’t see any
+return in the console. You can see it running with dockerps:
+
$ docker ps
+
+CONTAINER ID IMAGE COMMAND CREATED ...
+f6d4ca9b2b22 mygame "/bin/sh -c 'evenn..." About a minute ago ...
+
+
+
Note the container ID, this is how you manage the container as it runs.
+
dockerlogsf6d4ca9b2b22
+
+
+
Looks at the STDOUT output of the container (i.e. the normal server log)
+
dockerlogs-ff6d4ca9b2b22
+
+
+
Tail the log (so it updates to your screen ‘live’).
+
dockerpausef6d4ca9b2b22
+
+
+
Suspend the state of the container.
+
dockerunpausef6d4ca9b2b22
+
+
+
Un-suspend it again after a pause. It will pick up exactly where it were.
+
dockerstopf6d4ca9b2b22
+
+
+
Stop the container. To get it up again you need to use dockerrun, specifying ports etc. A new
+container will get a new container id to reference.
The evennia/evennia docker image holds the evennia library and all of its dependencies. It also
+has an ONBUILD directive which is triggered during builds of images derived from it. This
+ONBUILD directive handles setting up a volume and copying your game directory code into the proper
+location within the container.
+
In most cases, the Dockerfile for an Evennia-based game will only need the FROMevennia/evennia:latest directive, and optionally a MAINTAINER directive if you plan to publish
+your image on Docker Hub and would like to provide contact info.
A new evennia/evennia image is built automatically whenever there is a new commit to the master
+branch of Evennia. It is possible to create your own custom evennia base docker image based on any
+arbitrary commit.
+
+
Use git tools to checkout the commit that you want to base your image upon. (In the example
+below, we’re checking out commit a8oc3d5b.)
+
+
gitcheckout-bmy-stable-brancha8oc3d5b
+
+
+
+
Change your working directory to the evennia directory containing Dockerfile. Note that
+Dockerfile has changed over time, so if you are going far back in the commit history you might
+want to bring a copy of the latest Dockerfile with you and use that instead of whatever version
+was used at the time.
+
Use the dockerbuild command to build the image based off of the currently checked out commit.
+The example below assumes your docker account is mydhaccount.
+
+
dockerbuild-tmydhaccount/evennia.
+
+
+
+
Now you have a base evennia docker image built off of a specific commit. To use this image to
+build your game, you would modify FROM directive in the Dockerfile for your game directory
+to be:
+
+
FROMmydhacct/evennia:latest
+
+
+
Note: From this point, you can also use the dockertag command to set a specific tag on your image
+and/or upload it into Docker Hub under your account.
+
+
At this point, build your game using the same dockerbuild command as usual. Change your
+working directory to be your game directory and run
The Docker ecosystem includes a tool called docker-compose, which can orchestrate complex multi-
+container applications, or in our case, store the default port and terminal parameters that we want
+specified every time we run our container. A sample docker-compose.yml file to run a containerized
+Evennia game in development might look like this:
Note that with this setup you lose the --user$UID option. The problem is that the variable
+UID is not available inside the configuration file docker-compose.yml. A workaround is to
+hardcode your user and group id. In a terminal run echo$UID:$GID and if for example you get
+1000:1000 you can add to docker-compose.yml a line user:1000:1000 just below the image:...
+line.
+(right-click and choose your browser’s equivalent of “view image” to see it full size)
+
This screenshot shows a vanilla install of the just started Evennia MUD server.
+In the bottom window we can see the log messages from the running server.
+
In the top left window we see the default website of our new game displayed in a web browser (it has
+shrunk to accomodate the smaller screen). Evennia contains its own webserver to serve this page. The
+default site shows some brief info about the database. From here you can also reach Django’s admin
+interface for editing the database online.
+
To the upper right is the included web-browser client showing a connection to the server on port
+4001. This allows users to access the game without downloading a mud client separately.
+
Bottom right we see a login into the stock game using a third-party MUD client
+(Mudlet). This connects to the server via the telnet protocol on port 4000.
Scripts are the out-of-character siblings to the in-character
+Objects. Scripts are so flexible that the “Script” is a bit limiting
+
+
we had to pick something to name them after all. Other possible names
+(depending on what you’d use them for) would be OOBObjects,
+StorageContainers or TimerObjects.
+
+
Scripts can be used for many different things in Evennia:
+
+
They can attach to Objects to influence them in various ways - or exist
+independently of any one in-game entity (so-called Global Scripts).
+
They can work as timers and tickers - anything that may change with Time. But
+they can also have no time dependence at all. Note though that if all you want
+is just to have an object method called repeatedly, you should consider using
+the TickerHandler which is more limited but is specialized on
+just this task.
+
They can describe State changes. A Script is an excellent platform for
+hosting a persistent, but unique system handler. For example, a Script could be
+used as the base to track the state of a turn-based combat system. Since
+Scripts can also operate on a timer they can also update themselves regularly
+to perform various actions.
+
They can act as data stores for storing game data persistently in the database
+(thanks to its ability to have Attributes).
+
They can be used as OOC stores for sharing data between groups of objects, for
+example for tracking the turns in a turn-based combat system or barter exchange.
+
+
Scripts are Typeclassed entities and are manipulated in a similar
+way to how it works for other such Evennia entities:
+
# create a new script
+new_script=evennia.create_script(key="myscript",typeclass=...)
+
+# search (this is always a list, also if there is only one match)
+list_of_myscript=evennia.search_script("myscript")
+
+
A Script is defined as a class and is created in the same way as other
+typeclassed entities. The class has several properties
+to control the timer-component of the scripts. These are all optional -
+leaving them out will just create a Script with no timer components (useful to act as
+a database store or to hold a persistent game system, for example).
+
This you can do for example in the module
+evennia/typeclasses/scripts.py. Below is an example Script
+Typeclass.
+
fromevenniaimportDefaultScript
+
+classMyScript(DefaultScript):
+
+ defat_script_creation(self):
+ self.key="myscript"
+ self.interval=60# 1 min repeat
+
+ defat_repeat(self):
+ # do stuff every minute
+
+
+
In mygame/typeclasses/scripts.py is the Script class which inherits from DefaultScript
+already. This is provided as your own base class to do with what you like: You can tweak Script if
+you want to change the default behavior and it is usually convenient to inherit from this instead.
+Here’s an example:
+
# for example in mygame/typeclasses/scripts.py
+ # Script class is defined at the top of this module
+
+ importrandom
+
+ classWeather(Script):
+ """
+ A timer script that displays weather info. Meant to
+ be attached to a room.
+
+ """
+ defat_script_creation(self):
+ self.key="weather_script"
+ self.desc="Gives random weather messages."
+ self.interval=60*5# every 5 minutes
+ self.persistent=True# will survive reload
+
+ defat_repeat(self):
+ "called every self.interval seconds."
+ rand=random.random()
+ ifrand<0.5:
+ weather="A faint breeze is felt."
+ elifrand<0.7:
+ weather="Clouds sweep across the sky."
+ else:
+ weather="There is a light drizzle of rain."
+ # send this message to everyone inside the object this
+ # script is attached to (likely a room)
+ self.obj.msg_contents(weather)
+
+
+
If we put this script on a room, it will randomly report some weather
+to everyone in the room every 5 minutes.
+
To activate it, just add it to the script handler (scripts) on an
+Room. That object becomes self.obj in the example above. Here we
+put it on a room called myroom:
+
myroom.scripts.add(scripts.Weather)
+
+
+
+
Note that typeclasses in your game dir is added to the setting TYPECLASS_PATHS.
+Therefore we don’t need to give the full path (typeclasses.scripts.Weather
+but only scripts.Weather above.
+
+
If you wanted to stop and delete that script on the Room. You could do that
+with the script handler by passing the delete method with the script key (self.key) as:
+
myroom.scripts.delete('weather_script')
+
+
+
+
Note that If no key is given, this will delete all scripts on the object!
+
+
You can also create scripts using the evennia.create_script function:
Note that if you were to give a keyword argument to create_script, that would
+override the default value in your Typeclass. So for example, here is an instance
+of the weather script that runs every 10 minutes instead (and also not survive
+a server reload):
A Script has all the properties of a typeclassed object, such as db and ndb(see
+Typeclasses). Setting key is useful in order to manage scripts (delete them by name
+etc). These are usually set up in the Script’s typeclass, but can also be assigned on the fly as
+keyword arguments to evennia.create_script.
+
+
desc - an optional description of the script’s function. Seen in script listings.
+
interval - how often the script should run. If interval==0 (default), this script has no
+timing component, will not repeat and will exist forever. This is useful for Scripts used for
+storage or acting as bases for various non-time dependent game systems.
+
start_delay - (bool), if we should wait interval seconds before firing for the first time or
+not.
+
repeats - How many times we should repeat, assuming interval>0. If repeats is set to <=0,
+the script will repeat indefinitely. Note that each firing of the script (including the first one)
+counts towards this value. So a Script with start_delay=False and repeats=1 will start,
+immediately fire and shut down right away.
+
persistent- if this script should survive a server reset or server shutdown. (You don’t need
+to set this for it to survive a normal reload - the script will be paused and seamlessly restart
+after the reload is complete).
+
+
There is one special property:
+
+
obj - the Object this script is attached to (if any). You should not need to set
+this manually. If you add the script to the Object with myobj.scripts.add(myscriptpath) or give
+myobj as an argument to the utils.create.create_script function, the obj property will be set
+to myobj for you.
+
+
It’s also imperative to know the hook functions. Normally, overriding
+these are all the customization you’ll need to do in Scripts. You can
+find longer descriptions of these in src/scripts/scripts.py.
+
+
at_script_creation() - this is usually where the script class sets things like interval and
+repeats; things that control how the script runs. It is only called once - when the script is
+first created.
+
is_valid() - determines if the script should still be running or not. This is called when
+running obj.scripts.validate(), which you can run manually, but which is also called by Evennia
+during certain situations such as reloads. This is also useful for using scripts as state managers.
+If the method returns False, the script is stopped and cleanly removed.
+
at_start() - this is called when the script starts or is unpaused. For persistent scripts this
+is at least once ever server startup. Note that this will always be called right away, also if
+start_delay is True.
+
at_repeat() - this is called every interval seconds, or not at all. It is called right away at
+startup, unless start_delay is True, in which case the system will wait interval seconds
+before calling.
+
at_stop() - this is called when the script stops for whatever reason. It’s a good place to do
+custom cleanup.
+
at_server_reload() - this is called whenever the server is warm-rebooted (e.g. with the
+@reload command). It’s a good place to save non-persistent data you might want to survive a
+reload.
+
at_server_shutdown() - this is called when a system reset or systems shutdown is invoked.
+
+
Running methods (usually called automatically by the engine, but possible to also invoke manually)
+
+
start() - this will start the script. This is called automatically whenever you add a new script
+to a handler. at_start() will be called.
+
stop() - this will stop the script and delete it. Removing a script from a handler will stop it
+automatically. at_stop() will be called.
+
pause() - this pauses a running script, rendering it inactive, but not deleting it. All
+properties are saved and timers can be resumed. This is called automatically when the server reloads
+and will not lead to the at_stop() hook being called. This is a suspension of the script, not a
+change of state.
+
unpause() - resumes a previously paused script. The at_start() hook will be called to allow
+it to reclaim its internal state. Timers etc are restored to what they were before pause. The server
+automatically unpauses all paused scripts after a server reload.
+
force_repeat() - this will forcibly step the script, regardless of when it would otherwise have
+fired. The timer will reset and the at_repeat() hook is called as normal. This also counts towards
+the total number of repeats, if limited.
+
time_until_next_repeat() - for timed scripts, this returns the time in seconds until it next
+fires. Returns None if interval==0.
+
remaining_repeats() - if the Script should run a limited amount of times, this tells us how many
+are currently left.
+
reset_callcount(value=0) - this allows you to reset the number of times the Script has fired. It
+only makes sense if repeats>0.
+
restart(interval=None,repeats=None,start_delay=None) - this method allows you to restart the
+Script in-place with different run settings. If you do, the at_stop hook will be called and the
+Script brought to a halt, then the at_start hook will be called as the Script starts up with your
+(possibly changed) settings. Any keyword left at None means to not change the original setting.
A script does not have to be connected to an in-game object. If not it is
+called a Global script. You can create global scripts by simply not supplying an object to store
+it on:
+
# adding a global script
+ fromevenniaimportcreate_script
+ create_script("typeclasses.globals.MyGlobalEconomy",
+ key="economy",persistent=True,obj=None)
+
+
+
Henceforth you can then get it back by searching for its key or other identifier with
+evennia.search_script. In-game, the scripts command will show all scripts.
+
Evennia supplies a convenient “container” called GLOBAL_SCRIPTS that can offer an easy
+way to access global scripts. If you know the name (key) of the script you can get it like so:
+
fromevenniaimportGLOBAL_SCRIPTS
+
+my_script=GLOBAL_SCRIPTS.my_script
+# needed if there are spaces in name or name determined on the fly
+another_script=GLOBAL_SCRIPTS.get("another script")
+# get all global scripts (this returns a Queryset)
+all_scripts=GLOBAL_SCRIPTS.all()
+# you can operate directly on the script
+GLOBAL_SCRIPTS.weather.db.current_weather="Cloudy"
+
+
+
+
+
Note that global scripts appear as properties on GLOBAL_SCRIPTS based on their key.
+If you were to create two global scripts with the same key (even with different typeclasses),
+the GLOBAL_SCRIPTS container will only return one of them (which one depends on order in
+the database). Best is to organize your scripts so that this does not happen. Otherwise, use
+evennia.search_script to get exactly the script you want.
+
+
There are two ways to make a script appear as a property on GLOBAL_SCRIPTS. The first is
+to manually create a new global script with create_script as mentioned above. Often you want this
+to happen automatically when the server starts though. For this you add a python global dictionary
+named GLOBAL_SCRIPTS to your settings.py file. The settings.py fie is located in
+mygame/conf/settings.py:
Here the key (myscript and storagescript above) is required, all other fields are optional. If
+typeclass is not given, a script of type settings.BASE_SCRIPT_TYPECLASS is assumed. The keys
+related to timing and intervals are only needed if the script is timed.
+
+
Note: Provide the full path to the scripts module in GLOBAL_SCRIPTS. In the example with
+another_script, you can also create separate script modules that exist beyond scrypt.py for
+further organizational requirements if needed.
+
+
Evennia will use the information in settings.GLOBAL_SCRIPTS to automatically create and start
+these
+scripts when the server starts (unless they already exist, based on their key). You need to reload
+the server before the setting is read and new scripts become available. You can then find the key
+you gave as properties on evennia.GLOBAL_SCRIPTS
+(such as evennia.GLOBAL_SCRIPTS.storagescript).
+
+
Note: Make sure that your Script typeclass does not have any critical errors. If so, you’ll see
+errors in your log and your Script will temporarily fall back to being a DefaultScript type.
+
+
Moreover, a script defined this way is guaranteed to exist when you try to access it:
+
fromevenniaimportGLOBAL_SCRIPTS
+# first stop the script
+GLOBAL_SCRIPTS.storagescript.stop()
+# running the `scripts` command now will show no storagescript
+# but below now it's recreated again!
+storage=GLOBAL_SCRIPTS.storagescript
+
+
+
That is, if the script is deleted, next time you get it from GLOBAL_SCRIPTS, it will use the
+information
+in settings to recreate it for you.
+
+
Note that if your goal with the Script is to store persistent data, you should set it as
+persistent=True, either in settings.GLOBAL_SCRIPTS or in the Scripts typeclass. Otherwise any
+data you wanted to store on it will be gone (since a new script of the same name is restarted
+instead).
Errors inside an timed, executing script can sometimes be rather terse or point to
+parts of the execution mechanism that is hard to interpret. One way to make it
+easier to debug scripts is to import Evennia’s native logger and wrap your
+functions in a try/catch block. Evennia’s logger can show you where the
+traceback occurred in your script.
In-game you can try out scripts using the @script command. In the
+evennia/contrib/tutorial_examples/bodyfunctions.py is a little example script
+that makes you do little ‘sounds’ at random intervals. Try the following to apply an
+example time-based script to your character.
+
> @script self = bodyfunctions.BodyFunctions
+
+
+
+
Note: Since evennia/contrib/tutorial_examples is in the default setting
+TYPECLASS_PATHS, we only need to specify the final part of the path,
+that is, bodyfunctions.BodyFunctions.
+
+
If you want to inflict your flatulence script on another person, place or
+thing, try something like the following:
Hackers these days aren’t discriminating, and their backgrounds range from bored teenagers to
+international intelligence agencies. Their scripts and bots endlessly crawl the web, looking for
+vulnerable systems they can break into. Who owns the system is irrelevant– it doesn’t matter if it
+belongs to you or the Pentagon, the goal is to take advantage of poorly-secured systems and see what
+resources can be controlled or stolen from them.
+
If you’re considering deploying to a cloud-based host, you have a vested interest in securing your
+applications– you likely have a credit card on file that your host can freely bill. Hackers pegging
+your CPU to mine cryptocurrency or saturating your network connection to participate in a botnet or
+send spam can run up your hosting bill, get your service suspended or get your address/site
+blacklisted by ISPs. It can be a difficult legal or political battle to undo this damage after the
+fact.
+
As a developer about to expose a web application to the threat landscape of the modern internet,
+here are a few tips to consider to increase the security of your Evennia install.
In case of emergency, check your logs! By default they are located in the server/logs/ folder.
+Here are some of the more important ones and why you should care:
+
+
http_requests.log will show you what HTTP requests have been made against Evennia’s built-in
+webserver (TwistedWeb). This is a good way to see if people are innocuously browsing your site or
+trying to break it through code injection.
+
portal.log will show you various networking-related information. This is a good place to check
+for odd or unusual types or amounts of connections to your game, or other networking-related
+issues– like when users are reporting an inability to connect.
+
server.log is the MUX administrator’s best friend. Here is where you’ll find information
+pertaining to who’s trying to break into your system by guessing at passwords, who created what
+objects, and more. If your game fails to start or crashes and you can’t tell why, this is the first
+place you should look for answers. Security-related events are prefixed with an [SS] so when
+there’s a problem you might want to pay special attention to those.
There are a few Evennia/Django options that are set when you first create your game to make it more
+obvious to you where problems arise. These options should be disabled before you push your game into
+production– leaving them on can expose variables or code someone with malicious intent can easily
+abuse to compromise your environment.
+
In server/conf/settings.py:
+
# Disable Django's debug mode
+DEBUG = False
+# Disable the in-game equivalent
+IN_GAME_ERRORS = False
+# If you've registered a domain name, force Django to check host headers. Otherwise leave this
+
+
+
as-is.
+# Note the leading period– it is not a typo!
+ALLOWED_HOSTS = [‘.example.com’]
If you decide to allow users to upload their own images to be served from your site, special care
+must be taken. Django will read the file headers to confirm it’s an image (as opposed to a document
+or zip archive), but code can be injected into an image
+fileafter the headers
+that can be interpreted as HTML and/or give an attacker a web shell through which they can access
+other filesystem resources.
Serve all user-uploaded assets from a separate domain or CDN (not a subdomain of the one you
+already have!). For example, you may be browsing reddit.com but note that all the user-submitted
+images are being served from the redd.it domain. There are both security and performance benefits
+to this (webservers tend to load local resources one-by-one, whereas they will request external
+resources in bulk).
+
If you don’t want to pay for a second domain, don’t understand what any of this means or can’t be
+bothered with additional infrastructure, then simply reprocess user images upon receipt using an
+image library. Convert them to a different format, for example. Destroy the originals!
The web interface allows visitors to see an informational page as well as log into a browser-based
+telnet client with which to access Evennia. It also provides authentication endpoints against which
+an attacker can attempt to validate stolen lists of credentials to see which ones might be shared by
+your users. Django’s security is robust, but if you don’t want/need these features and fully intend
+to force your users to use traditional clients to access your game, you might consider disabling
+either/both to minimize your attack surface.
+
In server/conf/settings.py:
+
# Disable the Javascript webclient
+WEBCLIENT_ENABLED = False
+# Disable the website altogether
+WEBSERVER_ENABLED = False
+
Automated attacks will often target port 22 seeing as how it’s the standard port for SSH traffic.
+Also,
+many public wifi hotspots block ssh traffic over port 22 so you might not be able to access your
+server from these locations if you like to work remotely or don’t have a home internet connection.
+
If you don’t intend on running a website or securing it with TLS, you can mitigate both problems by
+changing the port used for ssh to 443, which most/all hotspot providers assume is HTTPS traffic and
+allows through.
+
(Ubuntu) In /etc/ssh/sshd_config, change the following variable:
+
# What ports, IPs and protocols we listen for
+Port 443
+
Though not officially supported, there are some benefits to deploying a webserver
+to handle/proxy traffic to your Evennia instance.
+
For example, Evennia’s game engine and webservice are tightly integrated. If you bring your game
+down for maintenance (or if it simply crashes) your website will go down with it. In these cases a
+standalone webserver can still be used to display a maintenance page or otherwise communicate to
+your users the reason for the downtime, instead of disappearing off the face of the earth and
+returning opaque SERVERNOTFOUND error messages.
+
Proper webservers are also written in more efficient programming languages than Python, and while
+Twisted can handle its own, putting a webserver in front of it is like hiring a bouncer to deal with
+nuisances and crowds before they even get in the door.
+
Many of the popular webservers also let you plug in additional modules (like
+mod_security for Apache) that can be used to detect
+(and block!) malicious users or requests before they even touch your game or site. There are also
+automated solutions for installing and configuring TLS (via Certbot/Let’s
+Encrypt) to secure your website against hotspot and
+ISP snooping.
Evennia runs out of the box without any changes to its settings. But there are several important
+ways to customize the server and expand it with your own plugins.
The “Settings” file referenced throughout the documentation is the file
+mygame/server/conf/settings.py. This is automatically created on the first run of evennia--init
+(see the Getting Started page).
+
Your new settings.py is relatively bare out of the box. Evennia’s core settings file is actually
+[evennia/settings_default.py](https://github.com/evennia/evennia/blob/master/evennia/settings_default
+.py) and is considerably more extensive (it is also heavily documented so you should refer to this
+file directly for the available settings).
+
Since mygame/server/conf/settings.py is a normal Python module, it simply imports
+evennia/settings_default.py into itself at the top.
+
This means that if any setting you want to change were to depend on some other default setting,
+you might need to copy & paste both in order to change them and get the effect you want (for most
+commonly changed settings, this is not something you need to worry about).
+
You should never edit evennia/settings_default.py. Rather you should copy&paste the select
+variables you want to change into your settings.py and edit them there. This will overload the
+previously imported defaults.
+
+
Warning: It may be tempting to copy everything from settings_default.py into your own settings
+file. There is a reason we don’t do this out of the box though: it makes it directly clear what
+changes you did. Also, if you limit your copying to the things you really need you will directly be
+able to take advantage of upstream changes and additions to Evennia for anything you didn’t
+customize.
Each setting appears as a property on the imported settings object. You can also explore all
+possible options with evennia.settings_full (this also includes advanced Django defaults that are
+not touched in default Evennia).
+
+
It should be pointed out that when importing settings into your code like this, it will be read
+only. You cannot edit your settings from your code! The only way to change an Evennia setting is
+to edit mygame/server/conf/settings.py directly. You also generally need to restart the server
+(possibly also the Portal) before a changed setting becomes available.
at_initial_setup.py - this allows you to add a custom startup method to be called (only) the
+very first time Evennia starts (at the same time as user #1 and Limbo is created). It can be made to
+start your own global scripts or set up other system/world-related things your game needs to have
+running from the start.
+
at_server_startstop.py - this module contains two functions that Evennia will call every time
+the Server starts and stops respectively - this includes stopping due to reloading and resetting as
+well as shutting down completely. It’s a useful place to put custom startup code for handlers and
+other things that must run in your game but which has no database persistence.
+
connection_screens.py - all global string variables in this module are interpreted by Evennia as
+a greeting screen to show when an Account first connects. If more than one string variable is
+present in the module a random one will be picked.
+
inlinefuncs.py - this is where you can define custom Inline functions.
+
inputfuncs.py - this is where you define custom Input functions to handle data
+from the client.
+
lockfuncs.py - this is one of many possible modules to hold your own “safe” lock functions to
+make available to Evennia’s Locks.
+
mssp.py - this holds meta information about your game. It is used by MUD search engines (which
+you often have to register with) in order to display what kind of game you are running along with
+statistics such as number of online accounts and online status.
+
oobfuncs.py - in here you can define custom OOB functions.
+
portal_services_plugin.py - this allows for adding your own custom services/protocols to the
+Portal. It must define one particular function that will be called by Evennia at startup. There can
+be any number of service plugin modules, all will be imported and used if defined. More info can be
+found here.
+
server_services_plugin.py - this is equivalent to the previous one, but used for adding new
+services to the Server instead. More info can be found
+here.
+
+
Some other Evennia systems can be customized by plugin modules but has no explicit template in
+conf/:
+
+
cmdparser.py - a custom module can be used to totally replace Evennia’s default command parser.
+All this does is to split the incoming string into “command name” and “the rest”. It also handles
+things like error messages for no-matches and multiple-matches among other things that makes this
+more complex than it sounds. The default parser is very generic, so you are most often best served
+by modifying things further down the line (on the command parse level) than here.
+
at_search.py - this allows for replacing the way Evennia handles search results. It allows to
+change how errors are echoed and how multi-matches are resolved and reported (like how the default
+understands that “2-ball” should match the second “ball” object if there are two of them in the
+room).
There is a special database model called ServerConf that stores server internal data and settings
+such as current account count (for interfacing with the webserver), startup status and many other
+things. It’s rarely of use outside the server core itself but may be good to
+know about if you are an Evennia developer.
An Evennia Session represents one single established connection to the server. Depending on the
+Evennia session, it is possible for a person to connect multiple times, for example using different
+clients in multiple windows. Each such connection is represented by a session object.
+
A session object has its own cmdset, usually the “unloggedin” cmdset. This is what
+is used to show the login screen and to handle commands to create a new account (or
+Account in evennia lingo) read initial help and to log into the game with an existing
+account. A session object can either be “logged in” or not. Logged in means that the user has
+authenticated. When this happens the session is associated with an Account object (which is what
+holds account-centric stuff). The account can then in turn puppet any number of objects/characters.
+
+
Warning: A Session is not persistent - it is not a Typeclass and has no
+connection to the database. The Session will go away when a user disconnects and you will lose any
+custom data on it if the server reloads. The .db handler on Sessions is there to present a uniform
+API (so you can assume .db exists even if you don’t know if you receive an Object or a Session),
+but this is just an alias to .ndb. So don’t store any data on Sessions that you can’t afford to
+lose in a reload. You have been warned.
The number of sessions possible to connect to a given account at the same time and how it works is
+given by the MULTISESSION_MODE setting:
+
+
MULTISESSION_MODE=0: One session per account. When connecting with a new session the old one is
+disconnected. This is the default mode and emulates many classic mud code bases. In default Evennia,
+this mode also changes how the createaccount Command works - it will automatically create a
+Character with the same name as the Account. When logging in, the login command is also modified
+to have the player automatically puppet that Character. This makes the distinction between Account
+and Character minimal from the player’s perspective.
+
MULTISESSION_MODE=1: Many sessions per account, input/output from/to each session is treated the
+same. For the player this means they can connect to the game from multiple clients and see the same
+output in all of them. The result of a command given in one client (that is, through one Session)
+will be returned to all connected Sessions/clients with no distinction. This mode will have the
+Session(s) auto-create and puppet a Character in the same way as mode 0.
+
MULTISESSION_MODE=2: Many sessions per account, one character per session. In this mode,
+puppeting an Object/Character will link the puppet back only to the particular Session doing the
+puppeting. That is, input from that Session will make use of the CmdSet of that Object/Character and
+outgoing messages (such as the result of a look) will be passed back only to that puppeting
+Session. If another Session tries to puppet the same Character, the old Session will automatically
+un-puppet it. From the player’s perspective, this will mean that they can open separate game clients
+and play a different Character in each using one game account.
+This mode will not auto-create a Character and not auto-puppet on login like in modes 0 and 1.
+Instead it changes how the account-cmdsets’s OOCLook command works so as to show a simple
+‘character select’ menu.
+
MULTISESSION_MODE=3: Many sessions per account and character. This is the full multi-puppeting
+mode, where multiple sessions may not only connect to the player account but multiple sessions may
+also puppet a single character at the same time. From the user’s perspective it means one can open
+multiple client windows, some for controlling different Characters and some that share a Character’s
+input/output like in mode 1. This mode otherwise works the same as mode 2.
+
+
+
Note that even if multiple Sessions puppet one Character, there is only ever one instance of that
+Character.
When you use msg() to return data to a user, the object on which you call the msg() matters. The
+MULTISESSION_MODE also matters, especially if greater than 1.
+
For example, if you use account.msg("hello") there is no way for evennia to know which session it
+should send the greeting to. In this case it will send it to all sessions. If you want a specific
+session you need to supply its session to the msg call (account.msg("hello",session=mysession)).
+
On the other hand, if you call the msg() message on a puppeted object, like
+character.msg("hello"), the character already knows the session that controls it - it will
+cleverly auto-add this for you (you can specify a different session if you specifically want to send
+stuff to another session).
+
Finally, there is a wrapper for msg() on all command classes: command.msg(). This will
+transparently detect which session was triggering the command (if any) and redirects to that session
+(this is most often what you want). If you are having trouble redirecting to a given session,
+command.msg() is often the safest bet.
+
You can get the session in two main ways:
+
+
Accounts and Objects (including Characters) have a sessions property.
+This is a handler that tracks all Sessions attached to or puppeting them. Use e.g.
+accounts.sessions.get() to get a list of Sessions attached to that entity.
+
A Command instance has a session property that always points back to the Session that triggered
+it (it’s always a single one). It will be None if no session is involved, like when a mob or
+script triggers the Command.
When would one want to customize the Session object? Consider for example a character creation
+system: You might decide to keep this on the out-of-character level. This would mean that you create
+the character at the end of some sort of menu choice. The actual char-create cmdset would then
+normally be put on the account. This works fine as long as you are MULTISESSION_MODE below 2.
+For higher modes, replacing the Account cmdset will affect all your connected sessions, also those
+not involved in character creation. In this case you want to instead put the char-create cmdset on
+the Session level - then all other sessions will keep working normally despite you creating a new
+character in one of them.
+
By default, the session object gets the commands.default_cmdsets.UnloggedinCmdSet when the user
+first connects. Once the session is authenticated it has no default sets. To add a “logged-in”
+cmdset to the Session, give the path to the cmdset class with settings.CMDSET_SESSION. This set
+will then henceforth always be present as soon as the account logs in.
+
To customize further you can completely override the Session with your own subclass. To replace the
+default Session class, change settings.SERVER_SESSION_CLASS to point to your custom class. This is
+a dangerous practice and errors can easily make your game unplayable. Make sure to take heed of the
+original and make your
+changes carefully.
Note: This is considered an advanced topic. You don’t need to know this on a first read-through.
+
Evennia is split into two parts, the Portal and the Server. Each side tracks
+its own Sessions, syncing them to each other.
+
The “Session” we normally refer to is actually the ServerSession. Its counter-part on the Portal
+side is the PortalSession. Whereas the server sessions deal with game states, the portal session
+deals with details of the connection-protocol itself. The two are also acting as backups of critical
+data such as when the server reboots.
+
New Account connections are listened for and handled by the Portal using the [protocols](Portal-And-
+Server) it understands (such as telnet, ssh, webclient etc). When a new connection is established, a
+PortalSession is created on the Portal side. This session object looks different depending on
+which protocol is used to connect, but all still have a minimum set of attributes that are generic
+to all
+sessions.
+
These common properties are piped from the Portal, through the AMP connection, to the Server, which
+is now informed a new connection has been established. On the Server side, a ServerSession object
+is created to represent this. There is only one type of ServerSession; It looks the same
+regardless of how the Account connects.
+
From now on, there is a one-to-one match between the ServerSession on one side of the AMP
+connection and the PortalSession on the other. Data arriving to the Portal Session is sent on to
+its mirror Server session and vice versa.
+
During certain situations, the portal- and server-side sessions are
+“synced” with each other:
+
+
The Player closes their client, killing the Portal Session. The Portal syncs with the Server to
+make sure the corresponding Server Session is also deleted.
+
The Player quits from inside the game, killing the Server Session. The Server then syncs with the
+Portal to make sure to close the Portal connection cleanly.
+
The Server is rebooted/reset/shutdown - The Server Sessions are copied over (“saved”) to the
+Portal side. When the Server comes back up, this data is returned by the Portal so the two are again
+in sync. This way an Account’s login status and other connection-critical things can survive a
+server reboot (assuming the Portal is not stopped at the same time, obviously).
Both the Portal and Server each have a sessionhandler to manage the connections. These handlers
+are global entities contain all methods for relaying data across the AMP bridge. All types of
+Sessions hold a reference to their respective Sessionhandler (the property is called
+sessionhandler) so they can relay data. See protocols for more info
+on building new protocols.
+
To get all Sessions in the game (i.e. all currently connected clients), you access the server-side
+Session handler, which you get by
PyCharm is a Python developer’s IDE from Jetbrains available
+for Windows, Mac and Linux. It is a commercial product but offer free trials, a scaled-down
+community edition and also generous licenses for OSS projects like Evennia.
+
+
This page was originally tested on Windows (so use Windows-style path examples), but should work
+the same for all platforms.
+
+
First, install Evennia on your local machine with [[Getting Started]]. If you’re new to PyCharm,
+loading your project is as easy as selecting the Open option when PyCharm starts, and browsing to
+your game folder (the one created with evennia--init). We refer to it as mygame here.
+
If you want to be able to examine evennia’s core code or the scripts inside your virtualenv, you’ll
+need to add them to your project too:
+
+
Go to File>Open...
+
Select the folder (i.e. the evennia root)
+
Select “Open in current window” and “Add to currently opened projects”
Launch Evennia in your preferred way (usually from a console/terminal)
+
Open your project in PyCharm
+
In the PyCharm menu, select Run>AttachtoLocalProcess...
+
From the list, pick the twistd process with the server.py parameter (Example: twistd.exe--nodaemon--logfile=\<mygame\>\server\logs\server.log--python=\<evenniarepo\>\evennia\server\server.py)
+
+
Of course you can attach to the portal process as well. If you want to debug the Evennia launcher
+or runner for some reason (or just learn how they work!), see Run Configuration below.
+
+
NOTE: Whenever you reload Evennia, the old Server process will die and a new one start. So when
+you restart you have to detach from the old and then reattach to the new process that was created.
+
+
+
To make the process less tedious you can apply a filter in settings to show only the server.py
+process in the list. To do that navigate to: Settings/Preferences|Build,Execution,Deployment|PythonDebugger and then in Attachtoprocess field put in: twistd.exe"--nodaemon. This is an
+example for windows, I don’t have a working mac/linux box.
+
This configuration allows you to launch Evennia from inside PyCharm. Besides convenience, it also
+allows suspending and debugging the evennia_launcher or evennia_runner at points earlier than you
+could by running them externally and attaching. In fact by the time the server and/or portal are
+running the launcher will have exited already.
+
+
Go to Run>EditConfigutations...
+
Click the plus-symbol to add a new configuration and choose Python
+
Add the script: \<yourrepo\>\evenv\Scripts\evennia_launcher.py (substitute your virtualenv if
+it’s not named evenv)
+
Set script parameters to: start-l (-l enables console logging)
+
Ensure the chosen interpreter is from your virtualenv
+
Set Working directory to your mygame folder (not evenv nor evennia)
+
You can refer to the PyCharm documentation for general info, but you’ll want to set at least a
+config name (like “MyMUD start” or similar).
+
+
Now set up a “stop” configuration by following the same steps as above, but set your Script
+parameters to: stop (and name the configuration appropriately).
+
A dropdown box holding your new configurations should appear next to your PyCharm run button.
+Select MyMUD start and press the debug icon to begin debugging. Depending on how far you let the
+program run, you may need to run your “MyMUD stop” config to actually stop the server, before you’ll
+be able start it again.
+
+
+
Alternative run configuration - utilizing logfiles as source of data¶
+
This configuration takes a bit different approach as instead of focusing on getting the data back
+through logfiles. Reason for that is this way you can easily separate data streams, for example you
+rarely want to follow both server and portal at the same time, and this will allow it. This will
+also make sure to stop the evennia before starting it, essentially working as reload command (it
+will also include instructions how to disable that part of functionality). We will start by defining
+a configuration that will stop evennia. This assumes that upfire is your pycharm project name, and
+also the game name, hence the upfire/upfire path.
+
+
Go to Run>EditConfigutations...\
+
Click the plus-symbol to add a new configuration and choose the python interpreter to use (should
+be project default)
+
Name the configuration as “stop evennia” and fill rest of the fields accordingly to the image:
+
+
Press Apply
+
+
Now we will define the start/reload command that will make sure that evennia is not running already,
+and then start the server in one go.
+
+
Go to Run>EditConfigutations...\
+
Click the plus-symbol to add a new configuration and choose the python interpreter to use (should
+be project default)
+
Name the configuration as “start evennia” and fill rest of the fields accordingly to the image:
+
+
Navigate to the Logs tab and add the log files you would like to follow. The picture shows
+adding portal.log which will show itself in portal tab when running:
+
+
Skip the following steps if you don’t want the launcher to stop evennia before starting.
+
Head back to Configuration tab and press the + sign at the bottom, under Beforelaunch....
+and select Runanotherconfiguration from the submenu that will pop up.
+
Click stopevennia and make sure that it’s added to the list like on the image above.
+
Click Apply and close the run configuration window.
+
+
You are now ready to go, and if you will fire up startevennia configuration you should see
+following in the bottom panel:
+
+and you can click through the tabs to check appropriate logs, or even the console output as it is
+still running in interactive mode.
This is feature available from evennia 0.9 and onward.
+
There are multiple ways for you to plug in your own functionality into Evennia.
+The most common way to do so is through hooks - methods on typeclasses that
+gets called at particular events. Hooks are great when you want a game entity
+to behave a certain way when something happens to it. Signals complements
+hooks for cases when you want to easily attach new functionality without
+overriding things on the typeclass.
+
When certain events happen in Evennia, a Signal is fired. The idea is that
+you can “attach” any number of event-handlers to these signals. You can attach
+any number of handlers and they’ll all fire whenever any entity triggers the
+signal.
This particular signal fires after (post) an Account has connected to the game.
+When that happens, myhandler will fire with the sender being the Account that just connected.
+
If you want to respond only to the effects of a specific entity you can do so
+like this:
All signals (including some django-specific defaults) are available in the module
+evennia.server.signals
+(with a shortcut evennia.signals). Signals are named by the sender type. So SIGNAL_ACCOUNT_*
+returns
+Account instances as senders, SIGNAL_OBJECT_* returns Objects etc. Extra keywords (kwargs)
+should
+be extracted from the **kwargs dict in the signal handler.
+
+
SIGNAL_ACCOUNT_POST_CREATE - this is triggered at the very end of Account.create(). Note that
+calling evennia.create.create_account (which is called internally by Account.create) will
+not
+trigger this signal. This is because using Account.create() is expected to be the most commonly
+used way for users to themselves create accounts during login. It passes and extra kwarg ip with
+the client IP of the connecting account.
+
SIGNAL_ACCOUNT_POST_LOGIN - this will always fire when the account has authenticated. Sends
+extra kwarg session with the new Session object involved.
+
SIGNAL_ACCCOUNT_POST_FIRST_LOGIN - this fires just before SIGNAL_ACCOUNT_POST_LOGIN but only
+if
+this is the first connection done (that is, if there are no previous sessions connected). Also
+passes the session along as a kwarg.
+
SIGNAL_ACCOUNT_POST_LOGIN_FAIL - sent when someone tried to log into an account by failed.
+Passes
+the session as an extra kwarg.
+
SIGNAL_ACCOUNT_POST_LOGOUT - always fires when an account logs off, no matter if other sessions
+remain or not. Passes the disconnecting session along as a kwarg.
+
SIGNAL_ACCOUNT_POST_LAST_LOGOUT - fires before SIGNAL_ACCOUNT_POST_LOGOUT, but only if this is
+the last Session to disconnect for that account. Passes the session as a kwarg.
+
SIGNAL_OBJECT_POST_PUPPET - fires when an account puppets this object. Extra kwargs session
+and account represent the puppeting entities.
+SIGNAL_OBJECT_POST_UNPUPPET - fires when the sending object is unpuppeted. Extra kwargs are
+session and account.
+
SIGNAL_ACCOUNT_POST_RENAME - triggered by the setting of Account.username. Passes extra
+kwargs old_name, new_name.
+
SIGNAL_TYPED_OBJECT_POST_RENAME - triggered when any Typeclassed entity’s key is changed.
+Extra
+kwargs passed are old_key and new_key.
+
SIGNAL_SCRIPT_POST_CREATE - fires when a script is first created, after any hooks.
+
SIGNAL_CHANNEL_POST_CREATE - fires when a Channel is first created, after any hooks.
+
SIGNAL_HELPENTRY_POST_CREATE - fires when a help entry is first created.
+
+
The evennia.signals module also gives you conveneient access to the default Django signals (these
+use a
+different naming convention).
+
+
pre_save - fired when any database entitiy’s .save method fires, before any saving has
+happened.
+
post_save - fires after saving a database entity.
+
pre_delete - fires just before a database entity is deleted.
+
post_delete - fires after a database entity was deleted.
+
pre_init - fires before a typeclass’ __init__ method (which in turn
+happens before the at_init hook fires).
+
post_init - triggers at the end of __init__ (still before the at_init hook).
+
+
These are highly specialized Django signals that are unlikely to be useful to most users. But
+they are included here for completeness.
+
+
m2m_changed - fires after a Many-to-Many field (like db_attributes) changes.
+
pre_migrate - fires before database migration starts with evenniamigrate.
+
post_migrate - fires after database migration finished.
+
request_started - sent when HTTP request begins.
+
request_finished - sent when HTTP request ends.
+
settings_changed - sent when changing settings due to @override_settings
+decorator (only relevant for unit testing)
+
template_rendered - sent when test system renders http template (only useful for unit tests).
+
connection_creation - sent when making initial connection to database.
Softcode is a very simple programming language that was created for in-game development on TinyMUD
+derivatives such as MUX, PennMUSH, TinyMUSH, and RhostMUSH. The idea is that by providing a stripped
+down, minimalistic language for in-game use, you can allow quick and easy building and game
+development to happen without having to learn C/C++. There is an added benefit of not having to have
+to hand out shell access to all developers, and permissions can be used to alleviate many security
+problems.
+
Writing and installing softcode is done through a MUD client. Thus it is not a formatted language.
+Each softcode function is a single line of varying size. Some functions can be a half of a page long
+or more which is obviously not very readable nor (easily) maintainable over time.
Pasting this into a MUX/MUSH and typing ‘hello’ will theoretically yield ‘Hello World!’, assuming
+certain flags are not set on your account object.
+
Setting attributes is done via @set. Softcode also allows the use of the ampersand (&) symbol.
+This shorter version looks like this:
+
&HELLO_WORLD.C me=$hello:@pemit %#=Hello World!
+
+
+
Perhaps I want to break the Hello World into an attribute which is retrieved when emitting:
+
&HELLO_VALUE.D me=Hello World
+ &HELLO_WORLD.C me=$hello:@pemit %#=[v(HELLO_VALUE.D)]
+
+
+
The v() function returns the HELLO_VALUE.D attribute on the object that the command resides
+(me, which is yourself in this case). This should yield the same output as the first example.
+
If you are still curious about how Softcode works, take a look at some external resources:
Softcode is excellent at what it was intended for: simple things. It is a great tool for making an
+interactive object, a room with ambiance, simple global commands, simple economies and coded
+systems. However, once you start to try to write something like a complex combat system or a higher
+end economy, you’re likely to find yourself buried under a mountain of functions that span multiple
+objects across your entire code.
+
Not to mention, softcode is not an inherently fast language. It is not compiled, it is parsed with
+each calling of a function. While MUX and MUSH parsers have jumped light years ahead of where they
+once were they can still stutter under the weight of more complex systems if not designed properly.
Now that starting text-based games is easy and an option for even the most technically inarticulate,
+new projects are a dime a dozen. People are starting new MUDs every day with varying levels of
+commitment and ability. Because of this shift from fewer, larger, well-staffed games to a bunch of
+small, one or two developer games, some of the benefit of softcode fades.
+
Softcode is great in that it allows a mid to large sized staff all work on the same game without
+stepping on one another’s toes. As mentioned before, shell access is not necessary to develop a MUX
+or a MUSH. However, now that we are seeing a lot more small, one or two-man shops, the issue of
+shell access and stepping on each other’s toes is a lot less.
Evennia shuns in-game softcode for on-disk Python modules. Python is a popular, mature and
+professional programming language. You code it using the conveniences of modern text editors.
+Evennia developers have access to the entire library of Python modules out there in the wild - not
+to mention the vast online help resources available. Python code is not bound to one-line functions
+on objects but complex systems may be organized neatly into real source code modules, sub-modules,
+or even broken out into entire Python packages as desired.
+
So what is not included in Evennia is a MUX/MOO-like online player-coding system. Advanced coding
+in Evennia is primarily intended to be done outside the game, in full-fledged Python modules.
+Advanced building is best handled by extending Evennia’s command system with your own sophisticated
+building commands. We feel that with a small development team you are better off using a
+professional source-control system (svn, git, bazaar, mercurial etc) anyway.
Adding advanced and flexible building commands to your game is easy and will probably be enough to
+satisfy most creative builders. However, if you really, really want to offer online coding, there
+is of course nothing stopping you from adding that to Evennia, no matter our recommendations. You
+could even re-implement MUX’ softcode in Python should you be very ambitious. The
+in-game-python is an optional
+pseudo-softcode plugin aimed at developers wanting to script their game from inside it.
The spawner is a system for defining and creating individual objects from a base template called a
+prototype. It is only designed for use with in-game Objects, not any other type of
+entity.
+
The normal way to create a custom object in Evennia is to make a Typeclass. If you
+haven’t read up on Typeclasses yet, think of them as normal Python classes that save to the database
+behind the scenes. Say you wanted to create a “Goblin” enemy. A common way to do this would be to
+first create a Mobile typeclass that holds everything common to mobiles in the game, like generic
+AI, combat code and various movement methods. A Goblin subclass is then made to inherit from
+Mobile. The Goblin class adds stuff unique to goblins, like group-based AI (because goblins are
+smarter in a group), the ability to panic, dig for gold etc.
+
But now it’s time to actually start to create some goblins and put them in the world. What if we
+wanted those goblins to not all look the same? Maybe we want grey-skinned and green-skinned goblins
+or some goblins that can cast spells or which wield different weapons? We could make subclasses of
+Goblin, like GreySkinnedGoblin and GoblinWieldingClub. But that seems a bit excessive (and a
+lot of Python code for every little thing). Using classes can also become impractical when wanting
+to combine them - what if we want a grey-skinned goblin shaman wielding a spear - setting up a web
+of classes inheriting each other with multiple inheritance can be tricky.
+
This is what the prototype is for. It is a Python dictionary that describes these per-instance
+changes to an object. The prototype also has the advantage of allowing an in-game builder to
+customize an object without access to the Python backend. Evennia also allows for saving and
+searching prototypes so other builders can find and use (and tweak) them later. Having a library of
+interesting prototypes is a good reasource for builders. The OLC system allows for creating, saving,
+loading and manipulating prototypes using a menu system.
+
The spawner takes a prototype and uses it to create (spawn) new, custom objects.
Enter the olc command or @spawn/olc to enter the prototype wizard. This is a menu system for
+creating, loading, saving and manipulating prototypes. It’s intended to be used by in-game builders
+and will give a better understanding of prototypes in general. Use help on each node of the menu
+for more information. Below are further details about how prototypes work and how they are used.
The prototype dictionary can either be created for you by the OLC (see above), be written manually
+in a Python module (and then referenced by the @spawn command/OLC), or created on-the-fly and
+manually loaded into the spawner function or @spawn command.
+
The dictionary defines all possible database-properties of an Object. It has a fixed set of allowed
+keys. When preparing to store the prototype in the database (or when using the OLC), some
+of these keys are mandatory. When just passing a one-time prototype-dict to the spawner the system
+is
+more lenient and will use defaults for keys not explicitly provided.
+
In dictionary form, a prototype can look something like this:
Note that the prototype dict as given on the command line must be a valid Python structure -
+so you need to put quotes around strings etc. For security reasons, a dict inserted from-in game
+cannot have any
+other advanced Python functionality, such as executable code, lambda etc. If builders are supposed
+to be able to use such features, you need to offer them through [$protfuncs](Spawner-and-
+Prototypes#protfuncs), embedded runnable functions that you have full control to check and vet
+before running.
All keys starting with prototype_ are for book keeping.
+
+
prototype_key - the ‘name’ of the prototype. While this can sometimes be skipped (such as when
+defining a prototype in a module or feeding a prototype-dict manually to the spawner function),
+it’s good
+practice to try to include this. It is used for book-keeping and storing of the prototype so you
+can find it later.
+
prototype_parent - If given, this should be the prototype_key of another prototype stored in
+the system or available in a module. This makes this prototype inherit the keys from the
+parent and only override what is needed. Give a tuple (parent1,parent2,...) for multiple
+left-right inheritance. If this is not given, a typeclass should usually be defined (below).
+
prototype_desc - this is optional and used when listing the prototype in in-game listings.
+
protototype_tags - this is optional and allows for tagging the prototype in order to find it
+easier later.
+
prototype_locks - two lock types are supported: edit and spawn. The first lock restricts
+the copying and editing of the prototype when loaded through the OLC. The second determines who
+may use the prototype to create new objects.
+
+
The remaining keys determine actual aspects of the objects to spawn from this prototype:
+
+
key - the main object identifier. Defaults to “Spawned Object X”, where X is a random
+integer.
+
typeclass - A full python-path (from your gamedir) to the typeclass you want to use. If not
+set, the prototype_parent should be
+defined, with typeclass defined somewhere in the parent chain. When creating a one-time
+prototype
+dict just for spawning, one could omit this - settings.BASE_OBJECT_TYPECLASS will be used
+instead.
+
location - this should be a #dbref.
+
home - a valid #dbref. Defaults to location or settings.DEFAULT_HOME if location does not
+exist.
+
destination - a valid #dbref. Only used by exits.
+
permissions - list of permission strings, like ["Accounts","may_use_red_door"]
+
locks - a lock-string like "edit:all();control:perm(Builder)"
+
aliases - list of strings for use as aliases
+
tags - list Tags. These are given as tuples (tag,category,data).
+
attrs - list of Attributes. These are given as tuples (attrname,value,category,lockstring)
+
Any other keywords are interpreted as non-category Attributes and their values.
+This is
+convenient for simple Attributes - use attrs for full control of Attributes.
+
+
Deprecated as of Evennia 0.8:
+
+
ndb_<name> - sets the value of a non-persistent attribute ("ndb_" is stripped from the name).
+This is simply not useful in a prototype and is deprecated.
+
exec - This accepts a code snippet or a list of code snippets to run. This should not be used -
+use callables or $protfuncs instead (see below).
Finally, the value can be a prototype function (Protfunc). These look like simple function calls
+that you embed in strings and that has a $ in front, like
+
{"key":"$choice(Urfgar, Rick the smelly, Blargh the foul)",
+ "attrs":{"desc":"This is a large $red(and very red) demon. "
+ "He has $randint(2,5) skulls in a chain around his neck."}
+
+
+
At execution time, the place of the protfunc will be replaced with the result of that protfunc being
+called (this is always a string). A protfunc works in much the same way as an
+InlineFunc - they are actually
+parsed using the same parser - except protfuncs are run every time the prototype is used to spawn a
+new object (whereas an inlinefunc is called when a text is returned to the user).
+
Here is how a protfunc is defined (same as an inlinefunc).
+
# this is a silly example, you can just color the text red with |r directly!
+defred(*args,**kwargs):
+ """
+ Usage: $red(<text>)
+ Returns the same text you entered, but red.
+ """
+ ifnotargsorlen(args)>1:
+ raiseValueError("Must have one argument, the text to color red!")
+ return"|r{}|n".format(args[0])
+
+
+
+
Note that we must make sure to validate input and raise ValueError if that fails. Also, it is
+not possible to use keywords in the call to the protfunc (so something like $echo(text,align=left) is invalid). The kwargs requred is for internal evennia use and not used at all for
+protfuncs (only by inlinefuncs).
+
+
To make this protfunc available to builders in-game, add it to a new module and add the path to that
+module to settings.PROT_FUNC_MODULES:
+
# in mygame/server/conf/settings.py
+
+PROT_FUNC_MODULES+=["world.myprotfuncs"]
+
+
+
+
All global callables in your added module will be considered a new protfunc. To avoid this (e.g.
+to have helper functions that are not protfuncs on their own), name your function something starting
+with _.
+
The default protfuncs available out of the box are defined in evennia/prototypes/profuncs.py. To
+override the ones available, just add the same-named function in your own protfunc module.
+
| Protfunc | Description |
+
| $random() | Returns random value in range [0, 1) |
+| $randint(start,end) | Returns random value in range [start, end] |
+| $left_justify(<text>) | Left-justify text |
+| $right_justify(<text>) | Right-justify text to screen width |
+| $center_justify(<text>) | Center-justify text to screen width |
+| $full_justify(<text>) | Spread text across screen width by adding spaces |
+| $protkey(<name>) | Returns value of another key in this prototype (self-reference) |
+| $add(<value1>,<value2>) | Returns value1 + value2. Can also be lists, dicts etc |
+| $sub(<value1>,<value2>) | Returns value1 - value2 |
+| $mult(<value1>,<value2>) | Returns value1 * value2 |
+| $div(<value1>,<value2>) | Returns value2 / value1 |
+| $toint(<value>) | Returns value converted to integer (or value if not possible) |
+| $eval(<code>) | Returns result of literal-
+eval of code string. Only simple
+python expressions. |
+| $obj(<query>) | Returns object #dbref searched globally by key, tag or #dbref. Error if more
+than one found.” |
+| $objlist(<query>) | Like $obj, except always returns a list of zero, one or more results. |
+| $dbref(dbref) | Returns argument if it is formed as a #dbref (e.g. #1234), otherwise error.
+
For developers with access to Python, using protfuncs in prototypes is generally not useful. Passing
+real Python functions is a lot more powerful and flexible. Their main use is to allow in-game
+builders to
+do limited coding/scripting for their prototypes without giving them direct access to raw Python.
Stored as Scripts in the database. These are sometimes referred to as database-
+prototypes This is the only way for in-game builders to modify and add prototypes. They have the
+advantage of being easily modifiable and sharable between builders but you need to work with them
+using in-game tools.
These prototypes are defined as dictionaries assigned to global variables in one of the modules
+defined in settings.PROTOTYPE_MODULES. They can only be modified from outside the game so they are
+are necessarily “read-only” from in-game and cannot be modified (but copies of them could be made
+into database-prototypes). These were the only prototypes available before Evennia 0.8. Module based
+prototypes can be useful in order for developers to provide read-only “starting” or “base”
+prototypes to build from or if they just prefer to work offline in an external code editor.
+
By default mygame/world/prototypes.py is set up for you to add your own prototypes. All global
+dicts in this module will be considered by Evennia to be a prototype. You could also tell Evennia
+to look for prototypes in more modules if you want:
+
# in mygame/server/conf/settings.py
+
+PROTOTYPE_MODULES+=["world.myownprototypes","combat.prototypes"]
+
+
+
+
+
Note the += operator in the above example. This will extend the already defined world.prototypes
+definition in the settings_default.py file in Evennia. If you would like to completely override the
+location of your PROTOTYPE_MODULES then set this to just = without the addition operator.
+
+
Here is an example of a prototype defined in a module:
+
# in a module Evennia looks at for prototypes,
+ # (like mygame/world/prototypes.py)
+
+ ORC_SHAMAN={"key":"Orc shaman",
+ "typeclass":"typeclasses.monsters.Orc",
+ "weapon":"wooden staff",
+ "health":20}
+
+
+
+
Note that in the example above, "ORC_SHAMAN" will become the prototype_key of this prototype.
+It’s the only case when prototype_key can be skipped in a prototype. However, if prototype_key
+was given explicitly, that would take precedence. This is a legacy behavior and it’s recommended
+that you always add prototype_key to be consistent.
The spawner can be used from inside the game through the Builder-only @spawn command. Assuming the
+“goblin” typeclass is available to the system (either as a database-prototype or read from module),
+you can spawn a new goblin with
+
@spawn goblin
+
+
+
You can also specify the prototype directly as a valid Python dictionary:
Note: The @spawn command is more lenient about the prototype dictionary than shown here. So you
+can for example skip the prototype_key if you are just testing a throw-away prototype. A random
+hash will be used to please the validation. You could also skip prototype_parent/typeclass - then
+the typeclass given by settings.BASE_OBJECT_TYPECLASS will be used.
All arguments are prototype dictionaries or the unique prototype_keys of prototypes
+known to the system (either database- or module-based). The function will return a matching list of
+created objects. Example:
Hint: Same as when using @spawn, when spawning from a one-time prototype dict like this, you can
+skip otherwise required keys, like prototype_key or typeclass/prototype_parent. Defaults will
+be used.
+
+
Note that no location will be set automatically when using evennia.prototypes.spawner.spawn(),
+you
+have to specify location explicitly in the prototype dict.
+
If the prototypes you supply are using prototype_parent keywords, the spawner will read prototypes
+from modules
+in settings.PROTOTYPE_MODULES as well as those saved to the database to determine the body of
+available parents. The spawn command takes many optional keywords, you can find its definition in
+the api docs.
You control Evennia from your game folder (we refer to it as mygame/ here), using the evennia
+program. If the evennia program is not available on the command line you must first install
+Evennia as described in the Getting Started page.
+
+
Hint: If you ever try the evennia command and get an error complaining that the command is not
+available, make sure your virtualenv is active.
+
+
Below are described the various management options. Run
Evennia consists of two components, the Evennia Server and Portal. Briefly,
+the Server is what is running the mud. It handles all game-specific things but doesn’t care
+exactly how players connect, only that they have. The Portal is a gateway to which players
+connect. It knows everything about telnet, ssh, webclient protocols etc but very little about the
+game. Both are required for a functioning mud.
+
evennia start
+
+
+
The above command will start the Portal, which in turn will boot up the Server. The command will
+print a summary of the process and unless there is an error you will see no further output. Both
+components will instead log to log files in mygame/server/logs/. For convenience you can follow
+those logs directly in your terminal by attaching -l to commands:
+
evennia -l
+
+
+
Will start following the logs of an already running server. When starting Evennia you can also do
Normally, Evennia runs as a ‘daemon’, in the background. If you want you can start either of the
+processes (but not both) as foreground processes in interactive mode. This means they will log
+directly to the terminal (rather than to log files that we then echo to the terminal) and you can
+kill the process (not just the log-file view) with Ctrl-C.
+
evennia istart
+
+
+
will start/restart the Server in interactive mode. This is required if you want to run a
+debugger. Next time you reload the server, it will return to normal mode.
+
evennia ipstart
+
+
+
will start the Portal in interactive mode. This is usually only necessary if you want to run
+Evennia under the control of some other type of process.
The act of reloading means the Portal will tell the Server to shut down and then boot it back up
+again. Everyone will get a message and the game will be briefly paused for all accounts as the
+server
+reboots. Since they are connected to the Portal, their connections are not lost.
+
Reloading is as close to a “warm reboot” you can get. It reinitializes all code of Evennia, but
+doesn’t kill “persistent” Scripts. It also calls at_server_reload() hooks on all
+objects so you
+can save eventual temporary properties you want.
+
From in-game the @reload command is used. You can also reload the server from outside the game:
+
evennia reload
+
+
+
Sometimes reloading from “the outside” is necessary in case you have added some sort of bug that
+blocks in-game input.
Resetting is the equivalent of a “cold reboot” - the Server will shut down and then restarted
+again, but will behave as if it was fully shut down. As opposed to a “real” shutdown, no accounts
+will be disconnected during a
+reset. A reset will however purge all non-persistent scripts and will call at_server_shutdown()
+hooks. It can be a good way to clean unsafe scripts during development, for example.
+
From in-game the @reset command is used. From the terminal:
A full shutdown closes Evennia completely, both Server and Portal. All accounts will be booted and
+systems saved and turned off cleanly.
+
From inside the game you initiate a shutdown with the @shutdown command. From command line you do
+
evennia stop
+
+
+
You will see messages of both Server and Portal closing down. All accounts will see the shutdown
+message and then be disconnected. The same effect happens if you press Ctrl+C while the server
+runs in interactive mode.
In the extreme case that neither of the server processes locks up and does not respond to commands,
+you can send them kill-signals to force them to shut down. To kill only the Server:
+
evennia skill
+
+
+
To kill both Server and Portal:
+
evennia kill
+
+
+
Note that this functionality is not supported on Windows.
If you should need to manually manage Evennia’s processors (or view them in a task manager program
+such as Linux’ top or the more advanced htop), you will find the following processes to be
+related to Evennia:
+
+
1 x twistd...evennia/server/portal/portal.py - this is the Portal process.
+
3 x twistd...server.py - One of these processes manages Evennia’s Server component, the main
+game. The other processes (with the same name but different process id) handle’s Evennia’s
+internal web server threads. You can look at mygame/server/server.pid to determine which is the
+main process.
During development, you will usually modify code and then reload the server to see your changes.
+This is done by Evennia re-importing your custom modules from disk. Usually bugs in a module will
+just have you see a traceback in the game, in the log or on the command line. For some really
+serious syntax errors though, your module might not even be recognized as valid Python. Evennia may
+then fail to restart correctly.
+
From inside the game you see a text about the Server restarting followed by an ever growing list of
+“…”. Usually this only lasts a very short time (up to a few seconds). If it seems to go on, it
+means the Portal is still running (you are still connected to the game) but the Server-component of
+Evennia failed to restart (that is, it remains in a shut-down state). Look at your log files or
+terminal to see what the problem is - you will usually see a clear traceback showing what went
+wrong.
+
Fix your bug then run
+
evennia start
+
+
+
Assuming the bug was fixed, this will start the Server manually (while not restarting the Portal).
+In-game you should now get the message that the Server has successfully restarted.
This tutorial describes the creation of an in-game map display based on a pre-drawn map. It also
+details how to use the Batch code processor for advanced building. There is
+also the Dynamic in-game map tutorial that works in the opposite direction,
+by generating a map from an existing grid of rooms.
+
Evennia does not require its rooms to be positioned in a “logical” way. Your exits could be named
+anything. You could make an exit “west” that leads to a room described to be in the far north. You
+could have rooms inside one another, exits leading back to the same room or describing spatial
+geometries impossible in the real world.
+
That said, most games do organize their rooms in a logical fashion, if nothing else to retain the
+sanity of their players. And when they do, the game becomes possible to map. This tutorial will give
+an example of a simple but flexible in-game map system to further help player’s to navigate. We will
+
To simplify development and error-checking we’ll break down the work into bite-size chunks, each
+building on what came before. For this we’ll make extensive use of the Batch code processor, so you may want to familiarize yourself with that.
+
+
Planning the map - Here we’ll come up with a small example map to use for the rest of the
+tutorial.
+
Making a map object - This will showcase how to make a static in-game “map” object a
+Character could pick up and look at.
+
Building the map areas - Here we’ll actually create the small example area according to the
+map we designed before.
+
Map code - This will link the map to the location so our output looks something like this:
+
crossroads(#3)
+↑╚∞╝↑
+≈↑│↑∩ The merger of two roads. To the north looms a mighty castle.
+O─O─O To the south, the glow of a campfire can be seen. To the east lie
+≈↑│↑∩ the vast mountains and to the west is heard the waves of the sea.
+↑▲O▲↑
+
+Exits: north(#8), east(#9), south(#10), west(#11)
+
+
+
+
+
We will henceforth assume your game folder is name named mygame and that you haven’t modified the
+default commands. We will also not be using Colors for our map since they
+don’t show in the documentation wiki.
Let’s begin with the fun part! Maps in MUDs come in many different shapes and sizes. Some appear as just boxes connected by lines. Others have complex graphics that are
+external to the game itself.
+
Our map will be in-game text but that doesn’t mean we’re restricted to the normal alphabet! If
+you’ve ever selected the Wingdings font in Microsoft Word
+you will know there are a multitude of other characters around to use. When creating your game with
+Evennia you have access to the UTF-8 character encoding which
+put at your disposal thousands of letters, number and geometric shapes.
+
For this exercise, we’ve copy-and-pasted from the pallet of special characters used over at Dwarf
+Fortress to create what is hopefully a
+pleasing and easy to understood landscape:
+
≈≈↑↑↑↑↑∩∩
+≈≈↑╔═╗↑∩∩ Places the account can visit are indicated by "O".
+≈≈↑║O║↑∩∩ Up the top is a castle visitable by the account.
+≈≈↑╚∞╝↑∩∩ To the right is a cottage and to the left the beach.
+≈≈≈↑│↑∩∩∩ And down the bottom is a camp site with tents.
+≈≈O─O─O⌂∩ In the center is the starting location, a crossroads
+≈≈≈↑│↑∩∩∩ which connect the four other areas.
+≈≈↑▲O▲↑∩∩
+≈≈↑↑▲↑↑∩∩
+≈≈↑↑↑↑↑∩∩
+
+
+
There are many considerations when making a game map depending on the play style and requirements
+you intend to implement. Here we will display a 5x5 character map of the area surrounding the
+account. This means making sure to account for 2 characters around every visitable location. Good
+planning at this stage can solve many problems before they happen.
In this section we will try to create an actual “map” object that an account can pick up and look
+at.
+
Evennia offers a range of default commands for creating objects and rooms
+in-game. While readily accessible, these commands are made to do very
+specific, restricted things and will thus not offer as much flexibility to experiment (for an
+advanced exception see in-line functions). Additionally, entering long
+descriptions and properties over and over in the game client can become tedious; especially when
+testing and you may want to delete and recreate things over and over.
+
To overcome this, Evennia offers batch processors that work as input-files
+created out-of-game. In this tutorial we’ll be using the more powerful of the two available batch
+processors, the Batch Code Processor , called with the @batchcode command.
+This is a very powerful tool. It allows you to craft Python files to act as blueprints of your
+entire game world. These files have access to use Evennia’s Python API directly. Batchcode allows
+for easy editing and creation in whatever text editor you prefer, avoiding having to manually build
+the world line-by-line inside the game.
+
+
Important warning: @batchcode’s power is only rivaled by the @py command. Batchcode is so
+powerful it should be reserved only for the superuser. Think carefully
+before you let others (such as Developer- level staff) run @batchcode on their own - make sure
+you are okay with them running arbitrary Python code on your server.
+
+
While a simple example, the map object it serves as good way to try out @batchcode. Go to
+mygame/world and create a new file there named batchcode_map.py:
+
# mygame/world/batchcode_map.py
+
+fromevenniaimportcreate_object
+fromevenniaimportDefaultObject
+
+# We use the create_object function to call into existence a
+# DefaultObject named "Map" wherever you are standing.
+
+map=create_object(DefaultObject,key="Map",location=caller.location)
+
+# We then access its description directly to make it our map.
+
+map.db.desc="""
+≈≈↑↑↑↑↑∩∩
+≈≈↑╔═╗↑∩∩
+≈≈↑║O║↑∩∩
+≈≈↑╚∞╝↑∩∩
+≈≈≈↑│↑∩∩∩
+≈≈O─O─O⌂∩
+≈≈≈↑│↑∩∩∩
+≈≈↑▲O▲↑∩∩
+≈≈↑↑▲↑↑∩∩
+≈≈↑↑↑↑↑∩∩
+"""
+
+# This message lets us know our map was created successfully.
+caller.msg("A map appears out of thin air and falls to the ground.")
+
+
+
Log into your game project as the superuser and run the command
+
@batchcodebatchcode_map
+
+
+
This will load your batchcode_map.py file and execute the code (Evennia will look in your world/
+folder automatically so you don’t need to specify it).
+
A new map object should have appeared on the ground. You can view the map by using lookmap. Let’s
+take it with the getmap command. We’ll need it in case we get lost!
We’ve just used batchcode to create an object useful for our adventures. But the locations on that
+map does not actually exist yet - we’re all mapped up with nowhere to go! Let’s use batchcode to
+build a game area based on our map. We have five areas outlined: a castle, a cottage, a campsite, a
+coastal beach and the crossroads which connects them. Create a new batchcode file for this in
+mygame/world, named batchcode_world.py.
+
# mygame/world/batchcode_world.py
+
+fromevenniaimportcreate_object,search_object
+fromtypeclassesimportrooms,exits
+
+# We begin by creating our rooms so we can detail them later.
+
+centre=create_object(rooms.Room,key="crossroads")
+north=create_object(rooms.Room,key="castle")
+east=create_object(rooms.Room,key="cottage")
+south=create_object(rooms.Room,key="camp")
+west=create_object(rooms.Room,key="coast")
+
+# This is where we set up the cross roads.
+# The rooms description is what we see with the 'look' command.
+
+centre.db.desc="""
+The merger of two roads. A single lamp post dimly illuminates the lonely crossroads.
+To the north looms a mighty castle. To the south the glow of a campfire can be seen.
+To the east lie a wall of mountains and to the west the dull roar of the open sea.
+"""
+
+# Here we are creating exits from the centre "crossroads" location to
+# destinations to the north, east, south, and west. We will be able
+# to use the exit by typing it's key e.g. "north" or an alias e.g. "n".
+
+centre_north=create_object(exits.Exit,key="north",
+ aliases=["n"],location=centre,destination=north)
+centre_east=create_object(exits.Exit,key="east",
+ aliases=["e"],location=centre,destination=east)
+centre_south=create_object(exits.Exit,key="south",
+ aliases=["s"],location=centre,destination=south)
+centre_west=create_object(exits.Exit,key="west",
+ aliases=["w"],location=centre,destination=west)
+
+# Now we repeat this for the other rooms we'll be implementing.
+# This is where we set up the northern castle.
+
+north.db.desc="An impressive castle surrounds you. " \
+ "There might be a princess in one of these towers."
+north_south=create_object(exits.Exit,key="south",
+ aliases=["s"],location=north,destination=centre)
+
+# This is where we set up the eastern cottage.
+
+east.db.desc="A cosy cottage nestled among mountains " \
+ "stretching east as far as the eye can see."
+east_west=create_object(exits.Exit,key="west",
+ aliases=["w"],location=east,destination=centre)
+
+# This is where we set up the southern camp.
+
+south.db.desc="Surrounding a clearing are a number of " \
+ "tribal tents and at their centre a roaring fire."
+south_north=create_object(exits.Exit,key="north",
+ aliases=["n"],location=south,destination=centre)
+
+# This is where we set up the western coast.
+
+west.db.desc="The dark forest halts to a sandy beach. " \
+ "The sound of crashing waves calms the soul."
+west_east=create_object(exits.Exit,key="east",
+ aliases=["e"],location=west,destination=centre)
+
+# Lastly, lets make an entrance to our world from the default Limbo room.
+
+limbo=search_object('Limbo')[0]
+limbo_exit=create_object(exits.Exit,key="enter world",
+ aliases=["enter"],location=limbo,destination=centre)
+
+
+
Apply this new batch code with @batchcodebatchcode_world. If there are no errors in the code we
+now have a nice mini-world to explore. Remember that if you get lost you can look at the map we
+created!
Now we have a landscape and matching map, but what we really want is a mini-map that displays
+whenever we move to a room or use the look command.
+
We could manually enter a part of the map into the description of every room like we did our map
+object description. But some MUDs have tens of thousands of rooms! Besides, if we ever changed our
+map we would have to potentially alter a lot of those room descriptions manually to match the
+change. So instead we will make one central module to hold our map. Rooms will reference this
+central location on creation and the map changes will thus come into effect when next running our
+batchcode.
+
To make our mini-map we need to be able to cut our full map into parts. To do this we need to put it
+in a format which allows us to do that easily. Luckily, python allows us to treat strings as lists
+of characters allowing us to pick out the characters we need.
+
mygame/world/map_module.py
+
# We place our map into a sting here.
+world_map="""\
+≈≈↑↑↑↑↑∩∩
+≈≈↑╔═╗↑∩∩
+≈≈↑║O║↑∩∩
+≈≈↑╚∞╝↑∩∩
+≈≈≈↑│↑∩∩∩
+≈≈O─O─O⌂∩
+≈≈≈↑│↑∩∩∩
+≈≈↑▲O▲↑∩∩
+≈≈↑↑▲↑↑∩∩
+≈≈↑↑↑↑↑∩∩
+"""
+
+# This turns our map string into a list of rows. Because python
+# allows us to treat strings as a list of characters, we can access
+# those characters with world_map[5][5] where world_map[row][column].
+world_map=world_map.split('\n')
+
+defreturn_map():
+ """
+ This function returns the whole map
+ """
+ map=""
+
+ #For each row in our map, add it to map
+ forvalueyinworld_map:
+ map+=valuey
+ map+="\n"
+
+ returnmap
+
+defreturn_minimap(x,y,radius=2):
+ """
+ This function returns only part of the map.
+ Returning all chars in a 2 char radius from (x,y)
+ """
+ map=""
+
+ #For each row we need, add the characters we need.
+ forvalueyinworld_map[y-radius:y+radius+1]:forvaluexinvaluey[x-radius:x+radius+1]:
+ map+=valuex
+ map+="\n"
+
+ returnmap
+
+
+
With our map_module set up, let’s replace our hardcoded map in mygame/world/batchcode_map.py with
+a reference to our map module. Make sure to import our map_module!
+
# mygame/world/batchcode_map.py
+
+fromevenniaimportcreate_object
+fromevenniaimportDefaultObject
+fromworldimportmap_module
+
+map=create_object(DefaultObject,key="Map",location=caller.location)
+
+map.db.desc=map_module.return_map()
+
+caller.msg("A map appears out of thin air and falls to the ground.")
+
+
+
Log into Evennia as the superuser and run this batchcode. If everything worked our new map should
+look exactly the same as the old map - you can use @delete to delete the old one (use a number to
+pick which to delete).
+
Now, lets turn our attention towards our game’s rooms. Let’s use the return_minimap method we
+created above in order to include a minimap in our room descriptions. This is a little more
+complicated.
+
By itself we would have to settle for either the map being above the description with
+room.db.desc=map_string+description_string, or the map going below by reversing their order.
+Both options are rather unsatisfactory - we would like to have the map next to the text! For this
+solution we’ll explore the utilities that ship with Evennia. Tucked away in evennia\evennia\utils
+is a little module called EvTable . This is an advanced ASCII table
+creator for you to utilize in your game. We’ll use it by creating a basic table with 1 row and two
+columns (one for our map and one for our text) whilst also hiding the borders. Open the batchfile
+again
+
# mygame\world\batchcode_world.py
+
+# Add to imports
+fromevennia.utilsimportevtable
+fromworldimportmap_module
+
+# [...]
+
+# Replace the descriptions with the below code.
+
+# The cross roads.
+# We pass what we want in our table and EvTable does the rest.
+# Passing two arguments will create two columns but we could add more.
+# We also specify no border.
+centre.db.desc=evtable.EvTable(map_module.return_minimap(4,5),
+ "The merger of two roads. A single lamp post dimly " \
+ "illuminates the lonely crossroads. To the north " \
+ "looms a mighty castle. To the south the glow of " \
+ "a campfire can be seen. To the east lie a wall of " \
+ "mountains and to the west the dull roar of the open sea.",
+ border=None)
+# EvTable allows formatting individual columns and cells. We use that here
+# to set a maximum width for our description, but letting the map fill
+# whatever space it needs.
+centre.db.desc.reformat_column(1,width=70)
+
+# [...]
+
+# The northern castle.
+north.db.desc=evtable.EvTable(map_module.return_minimap(4,2),
+ "An impressive castle surrounds you. There might be " \
+ "a princess in one of these towers.",
+ border=None)
+north.db.desc.reformat_column(1,width=70)
+
+# [...]
+
+# The eastern cottage.
+east.db.desc=evtable.EvTable(map_module.return_minimap(6,5),
+ "A cosy cottage nestled among mountains stretching " \
+ "east as far as the eye can see.",
+ border=None)
+east.db.desc.reformat_column(1,width=70)
+
+# [...]
+
+# The southern camp.
+south.db.desc=evtable.EvTable(map_module.return_minimap(4,7),
+ "Surrounding a clearing are a number of tribal tents " \
+ "and at their centre a roaring fire.",
+ border=None)
+south.db.desc.reformat_column(1,width=70)
+
+# [...]
+
+# The western coast.
+west.db.desc=evtable.EvTable(map_module.return_minimap(2,5),
+ "The dark forest halts to a sandy beach. The sound of " \
+ "crashing waves calms the soul.",
+ border=None)
+west.db.desc.reformat_column(1,width=70)
+
+
+
Before we run our new batchcode, if you are anything like me you would have something like 100 maps
+lying around and 3-4 different versions of our rooms extending from limbo. Let’s wipe it all and
+start with a clean slate. In Command Prompt you can run evenniaflush to clear the database and
+start anew. It won’t reset dbref values however, so if you are at #100 it will start from there.
+Alternatively you can navigate to mygame/server and delete the evennia.db3 file. Now in Command
+Prompt use evenniamigrate to have a completely freshly made database.
+
Log in to evennia and run @batchcodebatchcode_world and you’ll have a little world to explore.
You should now have a mapped little world and a basic understanding of batchcode, EvTable and how
+easily new game defining features can be added to Evennia.
A common task of a game designer is to organize and find groups of objects and do operations on
+them. A classic example is to have a weather script affect all “outside” rooms. Another would be for
+a player casting a magic spell that affects every location “in the dungeon”, but not those
+“outside”. Another would be to quickly find everyone joined with a particular guild or everyone
+currently dead.
+
Tags are short text labels that you attach to objects so as to easily be able to retrieve and
+group them. An Evennia entity can be tagged with any number of Tags. On the database side, Tag
+entities are shared between all objects with that tag. This makes them very efficient but also
+fundamentally different from Attributes, each of which always belongs to one single
+object.
+
In Evennia, Tags are technically also used to implement Aliases (alternative names for objects)
+and Permissions (simple strings for Locks to check for).
Tags are unique per object model. This means that for each object model (Objects, Scripts,
+Msgs, etc.) there is only ever one Tag object with a given key and category.
+
+
Not specifying a category (default) gives the tag a category of None, which is also considered a
+unique key + category combination.
+
+
When Tags are assigned to game entities, these entities are actually sharing the same Tag. This
+means that Tags are not suitable for storing information about a single object - use an
+Attribute for this instead. Tags are a lot more limited than Attributes but this also
+makes them very quick to lookup in the database - this is the whole point.
+
Tags have the following properties, stored in the database:
+
+
key - the name of the Tag. This is the main property to search for when looking up a Tag.
+
category - this category allows for retrieving only specific subsets of tags used for
+different purposes. You could have one category of tags for “zones”, another for “outdoor
+locations”, for example. If not given, the category will be None, which is also considered a
+separate, default, category.
+
data - this is an optional text field with information about the tag. Remember that Tags are
+shared between entities, so this field cannot hold any object-specific information. Usually it would
+be used to hold info about the group of entities the Tag is tagging - possibly used for contextual
+help like a tool tip. It is not used by default.
+
+
There are also two special properties. These should usually not need to be changed or set, it is
+used internally by Evennia to implement various other uses it makes of the Tag object:
+
+
model - this holds a natural-key description of the model object that this tag deals with,
+on the form application.modelclass, for example objects.objectdb. It used by the TagHandler of
+each entity type for correctly storing the data behind the scenes.
+
tagtype - this is a “top-level category” of sorts for the inbuilt children of Tags, namely
+Aliases and Permissions. The Taghandlers using this special field are especially intended to
+free up the category property for any use you desire.
You can tag any typeclassed object, namely Objects, Accounts,
+Scripts and Channels. General tags are added by the Taghandler. The
+tag handler is accessed as a property tags on the relevant entity:
+
mychair.tags.add("furniture")
+ mychair.tags.add("furniture",category="luxurious")
+ myroom.tags.add("dungeon#01")
+ myscript.tags.add("weather",category="climate")
+ myaccount.tags.add("guestaccount")
+
+ mychair.tags.all()# returns a list of Tags
+ mychair.tags.remove("furniture")
+ mychair.tags.clear()
+
+
+
Adding a new tag will either create a new Tag or re-use an already existing one. Note that there are
+two “furniture” tags, one with a None category, and one with the “luxurious” category.
+
When using remove, the Tag is not deleted but are just disconnected from the tagged object. This
+makes for very quick operations. The clear method removes (disconnects) all Tags from the object.
+You can also use the default @tag command:
+
@tag mychair = furniture
+
+
+
This tags the chair with a ‘furniture’ Tag (the one with a None category).
Note that searching for just “furniture” will only return the objects tagged with the “furniture”
+tag that
+has a category of None. We must explicitly give the category to get the “luxurious” furniture.
+
+
Using any of the search_tag variants will all return Django
+Querysets, including if you only have
+one match. You can treat querysets as lists and iterate over them, or continue building search
+queries with them.
+
Remember when searching that not setting a category means setting it to None - this does not
+mean that category is undefined, rather None is considered the default, unnamed category.
+
importevennia
+
+myobj1.tags.add("foo")# implies category=None
+myobj2.tags.add("foo",category="bar")
+
+# this returns a queryset with *only* myobj1
+objs=evennia.search_tag("foo")
+
+# these return a queryset with *only* myobj2
+objs=evennia.search_tag("foo",category="bar")
+# or
+objs=evennia.search_tag(category="bar")
+
+
+
+
There is also an in-game command that deals with assigning and using (Object-) tags:
Aliases and Permissions are implemented using normal TagHandlers that simply save Tags with a
+different tagtype. These handlers are named aliases and permissions on all Objects. They are
+used in the same way as Tags above:
Generally, tags are enough on their own for grouping objects. Having no tag category is perfectly
+fine and the normal operation. Simply adding a new Tag for grouping objects is often better than
+making a new category. So think hard before deciding you really need to categorize your Tags.
+
That said, tag categories can be useful if you build some game system that uses tags. You can then
+use tag categories to make sure to separate tags created with this system from any other tags
+created elsewhere. You can then supply custom search methods that only find objects tagged with
+tags of that category. An example of this
+is found in the Zone tutorial.
Evennia is a text-based game server. This makes it important to understand how
+it actually deals with data in the form of text.
+
Text byte encodings describe how a string of text is actually stored in the
+computer - that is, the particular sequence of bytes used to represent the
+letters of your particular alphabet. A common encoding used in English-speaking
+languages is the ASCII encoding. This describes the letters in the English
+alphabet (Aa-Zz) as well as a bunch of special characters. For describing other
+character sets (such as that of other languages with other letters than
+English), sets with names such as Latin-1, ISO-8859-3 and ARMSCII-8
+are used. There are hundreds of different byte encodings in use around the
+world.
+
A string of letters in a byte encoding is represented with the bytes type.
+In contrast to the byte encoding is the unicode representation. In Python
+this is the str type. The unicode is an internationally agreed-upon table
+describing essentially all available letters you could ever want to print.
+Everything from English to Chinese alphabets and all in between. So what
+Evennia (as well as Python and Django) does is to store everything in Unicode
+internally, but then converts the data to one of the encodings whenever
+outputting data to the user.
+
An easy memory aid is that bytes are what are sent over the network wire. At
+all other times, str (unicode) is used. This means that we must convert
+between the two at the points where we send/receive network data.
+
The problem is that when receiving a string of bytes over the network it’s
+impossible for Evennia to guess which encoding was used - it’s just a bunch of
+bytes! Evennia must know the encoding in order to convert back and from the
+correct unicode representation.
As long as you stick to the standard ASCII character set (which means the
+normal English characters, basically) you should not have to worry much
+about this section.
+
If you want to build your game in another language however, or expect your
+users to want to use special characters not in ASCII, you need to consider
+which encodings you want to support.
+
As mentioned, there are many, many byte-encodings used around the world. It
+should be clear at this point that Evennia can’t guess but has to assume or
+somehow be told which encoding you want to use to communicate with the server.
+Basically the encoding used by your client must be the same encoding used by
+the server. This can be customized in two complementary ways.
+
+
Point users to the default @encoding command or the @options command.
+This allows them to themselves set which encoding they (and their client of
+choice) uses. Whereas data will remain stored as unicode strings internally in
+Evennia, all data received from and sent to this particular player will be
+converted to the given format before transmitting.
+
As a back-up, in case the user-set encoding translation is erroneous or
+fails in some other way, Evennia will fall back to trying with the names
+defined in the settings variable ENCODINGS. This is a list of encoding
+names Evennia will try, in order, before giving up and giving an encoding
+error message.
+
+
Note that having to try several different encodings every input/output adds
+unneccesary overhead. Try to guess the most common encodings you players will
+use and make sure these are tried first. The International UTF-8 encoding is
+what Evennia assumes by default (and also what Python/Django use normally). See
+the Wikipedia article here for more help.
This documentation details the various text tags supported by Evennia, namely colours, command
+links and inline functions.
+
There is also an Understanding Color Tags tutorial which expands on the
+use of ANSI color tags and the pitfalls of mixing ANSI and Xterms256 color tags in the same context.
Note that the Documentation does not display colour the way it would look on the screen.
+
Color can be a very useful tool for your game. It can be used to increase readability and make your
+game more appealing visually.
+
Remember however that, with the exception of the webclient, you generally don’t control the client
+used to connect to the game. There is, for example, one special tag meaning “yellow”. But exactly
+which hue of yellow is actually displayed on the user’s screen depends on the settings of their
+particular mud client. They could even swap the colours around or turn them off altogether if so
+desired. Some clients don’t even support color - text games are also played with special reading
+equipment by people who are blind or have otherwise diminished eyesight.
+
So a good rule of thumb is to use colour to enhance your game but don’t rely on it to display
+critical information. If you are coding the game, you can add functionality to let users disable
+colours as they please, as described here.
+
To see which colours your client support, use the default @color command. This will list all
+available colours for ANSI and Xterm256 along with the codes you use for them. You can find a list
+of all the parsed ANSI-colour codes in evennia/utils/ansi.py.
Evennia supports the ANSI standard for text. This is by far the most supported MUD-color standard,
+available in all but the most ancient mud clients. The ANSI colours are red, green,
+yellow, blue, magenta, cyan, white and black. They are abbreviated by their
+first letter except for black which is abbreviated with the letter x. In ANSI there are “bright”
+and “normal” (darker) versions of each color, adding up to a total of 16 colours to use for
+foreground text. There are also 8 “background” colours. These have no bright alternative in ANSI
+(but Evennia uses the Xterm256 extension behind the scenes to offer
+them anyway).
+
To colour your text you put special tags in it. Evennia will parse these and convert them to the
+correct markup for the client used. If the user’s client/console/display supports ANSI colour, they
+will see the text in the specified colour, otherwise the tags will be stripped (uncolored text).
+This works also for non-terminal clients, such as the webclient. For the webclient, Evennia will
+translate the codes to HTML RGB colors.
+
Here is an example of the tags in action:
+
|rThis text is bright red.|n This is normal text.
+ |RThis is a dark red text.|n This is normal text.
+ |[rThis text has red background.|n This is normal text.
+ |b|[yThis is bright blue text on yellow background.|n This is normal text.
+
+
+
+
|n - this tag will turn off all color formatting, including background colors.
+
|#- markup marks the start of foreground color. The case defines if the text is “bright” or
+“normal”. So |g is a bright green and |G is “normal” (darker) green.
+
|[# is used to add a background colour to the text. The case again specifies if it is “bright”
+or “normal”, so |[c starts a bright cyan background and |[C a darker cyan background.
+
|!# is used to add foreground color without any enforced brightness/normal information.
+These are normal-intensity and are thus always given as uppercase, such as
+|!R for red. The difference between e.g. |!R and |R is that
+|!R will “inherit” the brightness setting from previously set color tags, whereas |R will
+always reset to the normal-intensity red. The |# format contains an implicit |h/|H tag in it:
+disabling highlighting when switching to a normal color, and enabling it for bright ones. So |btest|!Rtest2 will result in a bright red test2 since the brightness setting from |b “bleeds over”.
+You could use this to for example quickly switch the intensity of a multitude of color tags. There
+is no background-color equivalent to |! style tags.
+
|h is used to make any following foreground ANSI colors bright (it has no effect on Xterm
+colors). This is only relevant to use with |! type tags and will be valid until the next |n,
+|H or normal (upper-case) |# tag. This tag will never affect background colors, those have to be
+set bright/normal explicitly. Technically, |h|!G is identical to |g.
+
|H negates the effects |h and returns all ANSI foreground colors (|! and | types) to
+‘normal’ intensity. It has no effect on background and Xterm colors.
+
+
+
Note: The ANSI standard does not actually support bright backgrounds like |[r - the standard
+only supports “normal” intensity backgrounds. To get around this Evennia instead implements these
+as Xterm256 colours behind the scenes. If the client does not support
+Xterm256 the ANSI colors will be used instead and there will be no visible difference between using
+upper- and lower-case background tags.
+
+
If you want to display an ANSI marker as output text (without having any effect), you need to escape
+it by preceding its | with another |:
+
sayThe||rANSImarkerchangestextcolortobrightred.
+
+
+
This will output the raw |r without any color change. This can also be necessary if you are doing
+ansi art that uses | with a letter directly following it.
+
Use the command
+
@color ansi
+
+
+
to get a list of all supported ANSI colours and the tags used to produce them.
+
A few additional ANSI codes are supported:
+
+
|/ A line break. You cannot put the normal Python \n line breaks in text entered inside the
+game (Evennia will filter this for security reasons). This is what you use instead: use the |/
+marker to format text with line breaks from the game command line.
+
`` This will translate into a TAB character. This will not always show (or show differently) to
+the client since it depends on their local settings. It’s often better to use multiple spaces.
+
|_ This is a space. You can usually use the normal space character, but if the space is at the
+end of the line, Evennia will likely crop it. This tag will not be cropped but always result in a
+space.
+
|* This will invert the current text/background colours. Can be useful to mark things (but see
+below).
The |* tag (inverse video) is an old ANSI standard and should usually not be used for more than to
+mark short snippets of text. If combined with other tags it comes with a series of potentially
+confusing behaviors:
+
+
The |* tag will only work once in a row:, ie: after using it once it won’t have an effect again
+until you declare another tag. This is an example:
+
Normaltext,|*reversedtext|*,stillreversedtext.
+
+
+
that is, it will not reverse to normal at the second |*. You need to reset it manually:
+
Normaltext,|*reversedtext|n,normalagain.
+
+
+
+
The |* tag does not take “bright” colors into account:
+
|RNormalred,|hnowbrightened.|*BGisnormalred.
+
+
+
+
+
So |* only considers the ‘true’ foreground color, ignoring any highlighting. Think of the bright
+state (|h) as something like like <strong> in HTML: it modifies the appearance of a normal
+foreground color to match its bright counterpart, without changing its normal color.
+
+
Finally, after a |*, if the previous background was set to a dark color (via |[), |!#) will
+actually change the background color instead of the foreground:
+
|*reversed text |!R now BG is red.
+
+
+
+
+
For a detailed explanation of these caveats, see the [Understanding Color Tags](Understanding-Color-
+Tags) tutorial. But most of the time you might be better off to simply avoid |* and mark your text
+manually instead.
The Xterm256 standard is a colour scheme that supports 256 colours for text and/or background.
+While this offers many more possibilities than traditional ANSI colours, be wary that too many text
+colors will be confusing to the eye. Also, not all clients support Xterm256 - these will instead see
+the closest equivalent ANSI color. You can mix Xterm256 tags with ANSI tags as you please.
+
|555 This is pure white text.|n This is normal text.
+|230 This is olive green text.
+|[300 This text has a dark red background.
+|005|[054 This is dark blue text on a bright cyan background.
+|=a This is a greyscale value, equal to black.
+|=m This is a greyscale value, midway between white and black.
+|=z This is a greyscale value, equal to white.
+|[=m This is a background greyscale value.
+
+
+
+
|### - markup consists of three digits, each an integer from 0 to 5. The three digits describe
+the amount of red, green and blue (RGB) components used in the colour. So |500 means
+maximum red and none of the other colours - the result is a bright red. |520 is red with a touch
+of green - the result is orange. As opposed to ANSI colors, Xterm256 syntax does not worry about
+bright/normal intensity, a brighter (lighter) color is just achieved by upping all RGB values with
+the same amount.
+
|[### - this works the same way but produces a coloured background.
+
|=# - markup produces the xterm256 gray scale tones, where # is a letter from a (black) to
+z (white). This offers many more nuances of gray than the normal |### markup (which only has
+four gray tones between solid black and white (|000, |111, |222, |333 and |444)).
+
|[=# - this works in the same way but produces background gray scale tones.
+
+
If you have a client that supports Xterm256, you can use
+
@color xterm256
+
+
+
to get a table of all the 256 colours and the codes that produce them. If the table looks broken up
+into a few blocks of colors, it means Xterm256 is not supported and ANSI are used as a replacement.
+You can use the @options command to see if xterm256 is active for you. This depends on if your
+client told Evennia what it supports - if not, and you know what your client supports, you may have
+to activate some features manually.
Evennia supports clickable links for clients that supports it. This marks certain text so it can be
+clicked by a mouse and trigger a given Evennia command. To support clickable links, Evennia requires
+the webclient or an third-party telnet client with MXP
+support (Note: Evennia only supports clickable links, no other MXP features).
+
+
|lc to start the link, by defining the command to execute.
+
|lt to continue with the text to show to the user (the link text).
+
|le to end the link text and the link definition.
+
+
All elements must appear in exactly this order to make a valid link. For example,
+
"If you go |lcnorth|ltto the north|le you will find a cottage."
+
+
+
This will display as “If you go to the north you will find a cottage.” where clicking the link
+will execute the command north. If the client does not support clickable links, only the link text
+will be shown.
Note: Inlinefuncs are not activated by default. To use them you need to add
+INLINEFUNC_ENABLED=True to your settings file.
+
+
Evennia has its own inline text formatting language, known as inlinefuncs. It allows the builder
+to include special function calls in code. They are executed dynamically by each session that
+receives them.
+
To add an inlinefunc, you embed it in a text string like this:
+
"A normal string with $funcname(arg, arg, ...) embedded inside it."
+
+
+
When this string is sent to a session (with the msg() method), these embedded inlinefuncs will be
+parsed. Their return value (which always is a string) replace their call location in the finalized
+string. The interesting thing with this is that the function called will have access to which
+session is seeing the string, meaning the string can end up looking different depending on who is
+looking. It could of course also vary depending on other factors like game time.
+
Any number of comma-separated arguments can be given (or none). No keywords are supported. You can
+also nest inlinefuncs by letting an argument itself also be another $funcname(arg,arg,...) call
+(down to a depth of nesting given by settings.INLINEFUNC_STACK_MAXSIZE). Function call resolution
+happens as in all programming languages inside-out, with the nested calls replacing the argument
+with their return strings before calling he parent.
+
>say"This is $pad(a center-padded text, 30,c,-) of width 30."
+ Yousay,"This is ---- a center-padded text----- of width 30."
+ >say"I roll a die and the result is ... a $random(1,6)!"
+ Yousay,"I roll a die and the result is ... a 5!"
+
+
+
A special case happens if wanting to use an inlinefunc argument that itself includes a comma - this
+would be parsed as an argument separator. To escape commas you can either escape each comma manually
+with a backslash \,, or you can embed the entire string in python triple-quotes """ or ''' -
+this will escape the entire argument, including commas and any nested inlinefunc calls within.
+
Only certain functions are available to use as inlinefuncs and the game developer may add their own
+functions as needed.
To add new inlinefuncs, edit the file mygame/server/conf/inlinefuncs.py.
+
All globally defined functions in this module are considered inline functions by the system. The
+only exception is functions whose name starts with an underscore _. An inlinefunc must be of the
+following form:
where *args denotes all the arguments this function will accept as an $inlinefunc. The inline
+function is expected to clean arguments and check that they are valid. If needed arguments are not
+given, default values should be used. The function should always return a string (even if it’s
+empty). An inlinefunc should never cause a traceback regardless of the input (but it could log
+errors if desired).
+
Note that whereas the function should accept **kwargs, keyword inputs are not usable in the call
+to the inlinefunction. The kwargs part is instead intended for Evennia to be able to supply extra
+information. Currently Evennia sends a single keyword to every inline function and that is
+session, which holds the serversession this text is targeted at. Through the session
+object, a lot of dynamic possibilities are opened up for your inline functions.
+
The settings.INLINEFUNC_MODULES configuration option is a list that decides which modules should
+be parsed for inline function definitions. This will include mygame/server/conf/inlinefuncs.py but
+more could be added. The list is read from left to right so if you want to overload default
+functions you just have to put your custom module-paths later in the list and name your functions
+the same as default ones.
+
Here is an example, the crop default inlinefunction:
+
fromevennia.utilsimportutils
+
+defcrop(*args,**kwargs):
+ """
+ Inlinefunc. Crops ingoing text to given widths.
+ Args:
+ text (str, optional): Text to crop.
+ width (str, optional): Will be converted to an integer. Width of
+ crop in characters.
+ suffix (str, optional): End string to mark the fact that a part
+ of the string was cropped. Defaults to `[...]`.
+ Kwargs:
+ session (Session): Session performing the crop.
+ Example:
+ `$crop(text, 50, [...])`
+
+ """
+ text,width,suffix="",78,"[...]"
+ nargs=len(args)
+ ifnargs>0:
+ text=args[0]
+ ifnargs>1:
+ width=int(args[1])ifargs[1].strip().isdigit()else78
+ ifnargs>2:
+ suffix=args[2]
+ returnutils.crop(text,width=width,suffix=suffix)
+
+
+
Another example, making use of the Session:
+
defcharactername(*args,**kwargs):
+ """
+ Inserts the character name of whomever sees the string
+ (so everyone will see their own name). Uses the account
+ name for OOC communications.
+
+ Example:
+ say "This means YOU, $charactername()!"
+
+ """
+ session=kwargs["session"]
+ ifsession.puppet:
+ returnkwargs["session"].puppet.key
+ else:
+ returnsession.account.key
+
+
+
Evennia itself offers the following default inline functions (mostly as examples):
+
+
crop(text,width,suffix) - See above.
+
pad(text,width,align,fillchar) - this pads the text to width (default 78), alignment (“c”,
+“l” or “r”, defaulting to “c”) and fill-in character (defaults to space). Example: $pad(40,l,-)
+
clr(startclr,text,endclr) - A programmatic way to enter colored text for those who don’t want
+to use the normal |c type color markers for some reason. The color argument is the same as the
+color markers except without the actual pre-marker, so |r would be just r. If endclr is not
+given, it defaults to resetting the color (n). Example: $clr(b,Abluetext)
+
space(number) - Inserts the given number of spaces. If no argument is given, use 4 spaces.
+
random(), random(max), random(min,max) - gives a random value between min and max. With no
+arguments, give 0 or 1 (on/off). If one argument, returns 0…max. If values are floats, random
+value will be a float (so random(1.0) replicates the normal Python random function).
One way to implement a dynamic MUD is by using “tickers”, also known as “heartbeats”. A ticker is a
+timer that fires (“ticks”) at a given interval. The tick triggers updates in various game systems.
Tickers are very common or even unavoidable in other mud code bases. Certain code bases are even
+hard-coded to rely on the concept of the global ‘tick’. Evennia has no such notion - the decision to
+use tickers is very much up to the need of your game and which requirements you have. The “ticker
+recipe” is just one way of cranking the wheels.
+
The most fine-grained way to manage the flow of time is of course to use Scripts. Many
+types of operations (weather being the classic example) are however done on multiple objects in the
+same way at regular intervals, and for this, storing separate Scripts on each object is inefficient.
+The way to do this is to use a ticker with a “subscription model” - let objects sign up to be
+triggered at the same interval, unsubscribing when the updating is no longer desired.
+
Evennia offers an optimized implementation of the subscription model - the TickerHandler. This is
+a singleton global handler reachable from evennia.TICKER_HANDLER. You can assign any callable (a
+function or, more commonly, a method on a database object) to this handler. The TickerHandler will
+then call this callable at an interval you specify, and with the arguments you supply when adding
+it. This continues until the callable un-subscribes from the ticker. The handler survives a reboot
+and is highly optimized in resource usage.
+
Here is an example of importing TICKER_HANDLER and using it:
+
# we assume that obj has a hook "at_tick" defined on itself
+ fromevenniaimportTICKER_HANDLERastickerhandler
+
+ tickerhandler.add(20,obj.at_tick)
+
+
+
That’s it - from now on, obj.at_tick() will be called every 20 seconds.
Note that you have to also supply interval to identify which subscription to remove. This is
+because the TickerHandler maintains a pool of tickers and a given callable can subscribe to be
+ticked at any number of different intervals.
+
The full definition of the tickerhandler.add method is
Here *args and **kwargs will be passed to callback every interval seconds. If persistent
+is False, this subscription will not survive a server reload.
+
Tickers are identified and stored by making a key of the callable itself, the ticker-interval, the
+persistent flag and the idstring (the latter being an empty string when not given explicitly).
+
Since the arguments are not included in the ticker’s identification, the idstring must be used to
+have a specific callback triggered multiple times on the same interval but with different arguments:
Note that, when we want to send arguments to our callback within a ticker handler, we need to
+specify idstring and persistent before, unless we call our arguments as keywords, which would
+often be more readable:
If you add a ticker with exactly the same combination of callback, interval and idstring, it will
+overload the existing ticker. This identification is also crucial for later removing (stopping) the
+subscription:
The callable can be on any form as long as it accepts the arguments you give to send to it in
+TickerHandler.add.
+
+
Note that everything you supply to the TickerHandler will need to be pickled at some point to be
+saved into the database. Most of the time the handler will correctly store things like database
+objects, but the same restrictions as for Attributes apply to what the TickerHandler
+may store.
+
+
When testing, you can stop all tickers in the entire game with tickerhandler.clear(). You can also
+view the currently subscribed objects with tickerhandler.all().
+
See the Weather Tutorial for an example of using the TickerHandler.
Using the TickerHandler may sound very useful but it is important to consider when not to use it.
+Even if you are used to habitually relying on tickers for everything in other code bases, stop and
+think about what you really need it for. This is the main point:
+
+
You should never use a ticker to catch changes.
+
+
Think about it - you might have to run the ticker every second to react to the change fast enough.
+Most likely nothing will have changed at a given moment. So you are doing pointless calls (since
+skipping the call gives the same result as doing it). Making sure nothing’s changed might even be
+computationally expensive depending on the complexity of your system. Not to mention that you might
+need to run the check on every object in the database. Every second. Just to maintain status quo
+…
+
Rather than checking over and over on the off-chance that something changed, consider a more
+proactive approach. Could you implement your rarely changing system to itself report when its
+status changes? It’s almost always much cheaper/efficient if you can do things “on demand”. Evennia
+itself uses hook methods for this very reason.
+
So, if you consider a ticker that will fire very often but which you expect to have no effect 99% of
+the time, consider handling things things some other way. A self-reporting on-demand solution is
+usually cheaper also for fast-updating properties. Also remember that some things may not need to be
+updated until someone actually is examining or using them - any interim changes happening up to that
+moment are pointless waste of computing time.
+
The main reason for needing a ticker is when you want things to happen to multiple objects at the
+same time without input from something else.
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 for an
+example dice roller. To implement at twitch-based system you basically need a few combat
+commands, possibly ones with a cooldown. You also need a game rule
+module that makes use of it. We will focus on the turn-based
+variety here.
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 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 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 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. We will only sketch such a module here for our end-turn
+combat resolution.
+
An attackcommand 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 is implemented as a stand-alone Script. 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. 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:
+
# mygame/typeclasses/combat_handler.py
+
+importrandom
+fromevenniaimportDefaultScript
+fromworld.rulesimportresolve_combat
+
+classCombatHandler(DefaultScript):
+ """
+ This implements the combat handler.
+ """
+
+ # standard Script hooks
+
+ defat_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
+ delself.db.characters[dbref]
+ delself.db.turn_actions[dbref]
+ delself.db.action_count[dbref]
+ delcharacter.ndb.combat_handler
+ character.cmdset.delete("commands.combat.CombatCmdSet")
+
+ defat_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.
+ """
+ forcharacterinself.db.characters.values():
+ self._init_character(character)
+
+ defat_stop(self):
+ "Called just before the script is stopped/destroyed."
+ forcharacterinlist(self.db.characters.values()):
+ # note: the list() call above disconnects list from database
+ self._cleanup_character(character)
+
+ defat_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.
+
+ """
+ ifself.ndb.normal_turn_end:
+ # we get here because the turn ended normally
+ # (force_repeat was called) - no msg output
+ delself.ndb.normal_turn_end
+ else:
+ # turn timeout
+ self.msg_all("Turn timer timed out. Continuing.")
+ self.end_turn()
+
+ # Combat-handler methods
+
+ defadd_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)
+
+ defremove_character(self,character):
+ "Remove combatant from handler"
+ ifcharacter.idinself.db.characters:
+ self._cleanup_character(character)
+ ifnotself.db.characters:
+ # if no more characters in battle, kill this handler
+ self.stop()
+
+ defmsg_all(self,message):
+ "Send message to all combatants"
+ forcharacterinself.db.characters.values():
+ character.msg(message)
+
+ defadd_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]
+ if0<=count<=1:# only allow 2 actions
+ self.db.turn_actions[dbref][count]=(action,character,target)
+ else:
+ # report if we already used too many actions
+ returnFalse
+ self.db.action_count[dbref]+=1
+ returnTrue
+
+ defcheck_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).
+ """
+ ifall(count>1forcountinself.db.action_count.values()):
+ self.ndb.normal_turn_end=True
+ self.force_repeat()
+
+ defend_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)
+
+ iflen(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
+ forcharacterinself.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.
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.
+
# mygame/commands/combat.py
+
+fromevenniaimportCommand
+
+classCmdHit(Command):
+ """
+ hit an enemy
+
+ Usage:
+ hit <target>
+
+ Strikes the given enemy with your current weapon.
+ """
+ key="hit"
+ aliases=["strike","slash"]
+ help_category="combat"
+
+ deffunc(self):
+ "Implements the command"
+ ifnotself.args:
+ self.caller.msg("Usage: hit <target>")
+ return
+ target=self.caller.search(self.args)
+ ifnottarget:
+ return
+ ok=self.caller.ndb.combat_handler.add_action("hit",
+ self.caller,
+ target)
+ ifok:
+ 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:
A general way to implement a rule module is found in the rule system tutorial. 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.
+
+
# mygame/world/rules.py
+
+importrandom
+
+# messages
+
+defresolve_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
+ forisubinrange(2):
+ # loop over sub-turns
+ messages=[]
+ forsubturnin(sub[isub]forsubinactiondict.values()):
+ # for each character, resolve the sub-turn
+ action,char,target=subturn
+ iftarget:
+ taction,tchar,ttarget=actiondict[target.id][isub]
+ ifaction=="hit":
+ iftaction=="parry"andttarget==char:
+ msg="%s tries to hit %s, but %s parries the attack!"
+ messages.append(msg%(char,tchar,tchar))
+ eliftaction=="defend"andrandom.random()<0.5:
+ msg="%s defends against the attack by %s."
+ messages.append(msg%(tchar,char))
+ eliftaction=="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))
+ elifaction=="parry":
+ iftaction=="hit":
+ msg="%s parries the attack by %s."
+ messages.append(msg%(char,tchar))
+ eliftaction=="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)
+ elifaction=="feint":
+ iftaction=="parry":
+ msg="%s feints past %s's parry, landing a hit!"
+ messages.append(msg%(char,tchar))
+ eliftaction=="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)
+ elifaction=="defend":
+ msg="%s defends."
+ messages.append(msg%char)
+ elifaction=="flee":
+ ifcharinflee:
+ 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)inflee.items():
+ iffleevalue==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.
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.
+
# mygame/commands/combat.py
+
+fromevenniaimportcreate_script
+
+classCmdAttack(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"
+
+ deffunc(self):
+ "Handle command"
+ ifnotself.args:
+ self.caller.msg("Usage: attack <target>")
+ return
+ target=self.caller.search(self.args)
+ ifnottarget:
+ return
+ # set up combat
+ iftarget.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 if you are unsure about how to do this.
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.
This tutorial shows the implementation of an NPC object that responds to characters entering their
+location. In this example the NPC has the option to respond aggressively or not, but any actions
+could be triggered this way.
+
One could imagine using a Script that is constantly checking for newcomers. This would be
+highly inefficient (most of the time its check would fail). Instead we handle this on-demand by
+using a couple of existing object hooks to inform the NPC that a Character has entered.
+
It is assumed that you already know how to create custom room and character typeclasses, please see
+the Basic Game tutorial if you haven’t already done this.
+
What we will need is the following:
+
+
An NPC typeclass that can react when someone enters.
+
A custom Room typeclass that can tell the NPC that someone entered.
+
We will also tweak our default Character typeclass a little.
+
+
To begin with, we need to create an NPC typeclass. Create a new file inside of your typeclasses
+folder and name it npcs.py and then add the following code:
+
fromtypeclasses.charactersimportCharacter
+
+classNPC(Character):
+ """
+ A NPC typeclass which extends the character class.
+ """
+ defat_char_entered(self,character):
+ """
+ A simple is_aggressive check.
+ Can be expanded upon later.
+ """
+ ifself.db.is_aggressive:
+ self.execute_cmd(f"say Graaah, die {character}!")
+ else:
+ self.execute_cmd(f"say Greetings, {character}!")
+
+
+
We will define our custom Character typeclass below. As for the new at_char_entered method we’ve
+just defined, we’ll ensure that it will be called by the room where the NPC is located, when a
+player enters that room. You’ll notice that right now, the NPC merely speaks. You can expand this
+part as you like and trigger all sorts of effects here (like combat code, fleeing, bartering or
+quest-giving) as your game design dictates.
+
Now your typeclasses.rooms module needs to have the following added:
+
# Add this import to the top of your file.
+fromevenniaimportutils
+
+ # Add this hook in any empty area within your Room class.
+ defat_object_receive(self,obj,source_location):
+ ifutils.inherits_from(obj,'typeclasses.npcs.NPC'):# An NPC has entered
+ return
+ elifutils.inherits_from(obj,'typeclasses.characters.Character'):
+ # A PC has entered.
+ # Cause the player's character to look around.
+ obj.execute_cmd('look')
+ foriteminself.contents:
+ ifutils.inherits_from(item,'typeclasses.npcs.NPC'):
+ # An NPC is in the room
+ item.at_char_entered(obj)
+
+
+
inherits_from must be given the full path of the class. If the object inherited a class from your
+world.races module, then you would check inheritance with world.races.Human, for example. There
+is no need to import these prior, as we are passing in the full path. As a matter of a fact,
+inherits_from does not properly work if you import the class and only pass in the name of the
+class.
+
+
Note:
+at_object_receive
+is a default hook of the DefaultObject typeclass (and its children). Here we are overriding this
+hook in our customized room typeclass to suit our needs.
+
+
This room checks the typeclass of objects entering it (using utils.inherits_from and responds to
+Characters, ignoring other NPCs or objects. When triggered the room will look through its
+contents and inform any NPCsinsidebycallingtheirat_char_entered` method.
+
You’ll also see that we have added a ‘look’ into this code. This is because, by default, the
+at_object_receive is carried out before the character’s at_after_move which, we will now
+overload. This means that a character entering would see the NPC perform its actions before the
+‘look’ command. Deactivate the look command in the default Character class within the
+typeclasses.characters module:
+
# Add this hook in any blank area within your Character class.
+ defat_after_move(self,source_location):
+ """
+ Default is to look around after a move
+ Note: This has been moved to Room.at_object_receive
+ """
+ #self.execute_cmd('look')
+ pass
+
+
+
Now let’s create an NPC and make it aggressive. Type the following commands into your MUD client:
+
reload
+create/dropOrc:npcs.NPC
+
+
+
+
Note: You could also give the path as typeclasses.npcs.NPC, but Evennia will look into the
+typeclasses folder automatically, so this is a little shorter.
+
+
When you enter the aggressive NPC’s location, it will default to using its peaceful action (say your
+name is Anna):
+
Orcsays,"Greetings, Anna!"
+
+
+
Now we turn on the aggressive mode (we do it manually but it could also be triggered by some sort of
+AI code).
+
setorc/is_aggressive=True
+
+
+
Now it will perform its aggressive action whenever a character enters.
This tutorial shows the implementation of an NPC object that responds to characters speaking in
+their location. In this example the NPC parrots what is said, but any actions could be triggered
+this way.
+
It is assumed that you already know how to create custom room and character typeclasses, please see
+the Basic Game tutorial if you haven’t already done this.
+
What we will need is simply a new NPC typeclass that can react when someone speaks.
+
# mygame/typeclasses/npc.py
+
+fromcharactersimportCharacter
+classNpc(Character):
+ """
+ A NPC typeclass which extends the character class.
+ """
+ defat_heard_say(self,message,from_obj):
+ """
+ A simple listener and response. This makes it easy to change for
+ subclasses of NPCs reacting differently to says.
+
+ """
+ # message will be on the form `<Person> says, "say_text"`
+ # we want to get only say_text without the quotes and any spaces
+ message=message.split('says, ')[1].strip(' "')
+
+ # we'll make use of this in .msg() below
+ return"%s said: '%s'"%(from_obj,message)
+
+
+
When someone in the room speaks to this NPC, its msg method will be called. We will modify the
+NPCs .msg method to catch says so the NPC can respond.
+
# mygame/typeclasses/npc.py
+
+fromcharactersimportCharacter
+classNpc(Character):
+
+ # [at_heard_say() goes here]
+
+ defmsg(self,text=None,from_obj=None,**kwargs):
+ "Custom msg() method reacting to say."
+
+ iffrom_obj!=self:
+ # make sure to not repeat what we ourselves said or we'll create a loop
+ try:
+ # if text comes from a say, `text` is `('say_text', {'type': 'say'})`
+ say_text,is_say=text[0],text[1]['type']=='say'
+ exceptException:
+ is_say=False
+ ifis_say:
+ # First get the response (if any)
+ response=self.at_heard_say(say_text,from_obj)
+ # If there is a response
+ ifresponse!=None:
+ # speak ourselves, using the return
+ self.execute_cmd("say %s"%response)
+
+ # this is needed if anyone ever puppets this NPC - without it you would never
+ # get any feedback from the server (not even the results of look)
+ super().msg(text=text,from_obj=from_obj,**kwargs)
+
+
+
So if the NPC gets a say and that say is not coming from the NPC itself, it will echo it using the
+at_heard_say hook. Some things of note in the above example:
+
+
The text input can be on many different forms depending on where this msg is called from.
+Instead of trying to analyze text in detail with a range of if statements we just assume the
+form we want and catch the error if it does not match. This simplifies the code considerably. It’s
+called ‘leap before you look’ and is a Python paradigm that may feel unfamiliar if you are used to
+other languages. Here we ‘swallow’ the error silently, which is fine when the code checked is
+simple. If not we may want to import evennia.logger.log_trace and add log_trace() in the
+except clause.
+If you would like to learn more about the text list used above refer to the Out-Of-Band
+documentation.
+
We use execute_cmd to fire the say command back. We could also have called
+self.location.msg_contents directly but using the Command makes sure all hooks are called (so
+those seeing the NPC’s say can in turn react if they want).
+
Note the comments about super at the end. This will trigger the ‘default’ msg (in the parent
+class) as well. It’s not really necessary as long as no one puppets the NPC (by @ic<npcname>) but
+it’s wise to keep in there since the puppeting player will be totally blind if msg() is never
+returning anything to them!
+
+
Now that’s done, let’s create an NPC and see what it has to say for itself.
+
@reload
+@create/dropGuildMaster:npc.Npc
+
+
+
(you could also give the path as typeclasses.npc.Npc, but Evennia will look into the typeclasses
+folder automatically so this is a little shorter).
+
> say hi
+You say, "hi"
+Guild Master says, "Anna said: 'hi'"
+
There are many ways to implement this kind of functionality. An alternative example to overriding
+msg would be to modify the at_say hook on the Character instead. It could detect that it’s
+sending to an NPC and call the at_heard_say hook directly.
+
While the tutorial solution has the advantage of being contained only within the NPC class,
+combining this with using the Character class gives more direct control over how the NPC will react.
+Which way to go depends on the design requirements of your particular game.
You will often want to operate on a specific object in the database. For example when a player
+attacks a named target you’ll need to find that target so it can be attacked. Or when a rain storm
+draws in you need to find all outdoor-rooms so you can show it raining in them. This tutorial
+explains Evennia’s tools for searching.
The first thing to consider is the base type of the thing you are searching for. Evennia organizes
+its database into a few main tables: Objects, Accounts, Scripts,
+Channels, Messages and Help Entries.
+Most of the time you’ll likely spend your time searching for Objects and the occasional Accounts.
+
So to find an entity, what can be searched for?
+
+
The key is the name of the entity. While you can get this from obj.key the database field
+is actually named obj.db_key - this is useful to know only when you do direct database
+queries. The one exception is Accounts, where
+the database field for .key is instead named username (this is a Django requirement). When you
+don’t specify search-type, you’ll usually search based on key. Aliases are extra names given to
+Objects using something like @alias or obj.aliases.add('name'). The main search functions (see
+below) will automatically search for aliases whenever you search by-key.
+
Tags are the main way to group and identify objects in Evennia. Tags can most often be
+used (sometimes together with keys) to uniquely identify an object. For example, even though you
+have two locations with the same name, you can separate them by their tagging (this is how Evennia
+implements ‘zones’ seen in other systems). Tags can also have categories, to further organize your
+data for quick lookups.
+
An object’s Attributes can also used to find an object. This can be very useful but
+since Attributes can store almost any data they are far less optimized to search for than Tags or
+keys.
+
The object’s Typeclass indicate the sub-type of entity. A Character, Flower or
+Sword are all types of Objects. A Bot is a kind of Account. The database field is called
+typeclass_path and holds the full Python-path to the class. You can usually specify the
+typeclass as an argument to Evennia’s search functions as well as use the class directly to limit
+queries.
+
The location is only relevant for Objects but is a very common way to weed down the
+number of candidates before starting to search. The reason is that most in-game commands tend to
+operate on things nearby (in the same room) so the choices can be limited from the start.
+
The database id or the ‘#dbref’ is unique (and never re-used) within each database table. So while
+there is one and only one Object with dbref #42 there could also be an Account or Script with the
+dbref #42 at the same time. In almost all search methods you can replace the “key” search
+criterion with "#dbref" to search for that id. This can occasionally be practical and may be what
+you are used to from other code bases. But it is considered bad practice in Evennia to rely on
+hard-coded #dbrefs to do your searches. It makes your code tied to the exact layout of the database.
+It’s also not very maintainable to have to remember abstract numbers. Passing the actual objects
+around and searching by Tags and/or keys will usually get you what you need.
All in-game Objects have a .contents property that returns all objects ‘inside’ them
+(that is, all objects which has its .location property set to that object. This is a simple way to
+get everything in a room and is also faster since this lookup is cached and won’t hit the database.
+
+
roomobj.contents returns a list of all objects inside roomobj.
+
obj.contents same as for a room, except this usually represents the object’s inventory
+
obj.location.contents gets everything in obj’s location (including obj itself).
+
roomobj.exits returns all exits starting from roomobj (Exits are here defined as Objects with
+their destination field set).
+
obj.location.contents_get(exclude=obj) - this helper method returns all objects in obj’s
+location except obj.
Say you have a command, and you want it to do something to a target. You might be
+wondering how you retrieve that target in code, and that’s where Evennia’s search utilities come in.
+In the most common case, you’ll often use the search method of the Object or Account
+typeclasses. In a command, the .caller property will refer back to the object using the command
+(usually a Character, which is a type of Object) while .args will contain Command’s arguments:
+
# e.g. in file mygame/commands/command.py
+
+fromevenniaimportdefault_cmds
+
+classCmdPoke(Command):
+ """
+ Pokes someone.
+
+ Usage: poke <target>
+ """
+ key="poke"
+
+ deffunc(self):
+ """Executes poke command"""
+ target=self.caller.search(self.args.lstrip())
+ ifnottarget:
+ # we didn't find anyone, but search has already let the
+ # caller know. We'll just return, since we're done
+ return
+ # we found a target! we'll do stuff to them.
+ target.msg(f"{self.caller} pokes you.")
+ self.caller.msg(f"You poke {target}.")
+
+
+
By default, the search method of a Character will attempt to find a unique object match for the
+string sent to it (self.args, in this case, which is the arguments passed to the command by the
+player) in the surroundings of the Character - the room or their inventory. If there is no match
+found, the return value (which is assigned to target) will be None, and an appropriate failure
+message will be sent to the Character. If there’s not a unique match, None will again be returned,
+and a different error message will be sent asking them to disambiguate the multi-match. By default,
+the user can then pick out a specific match using with a number and dash preceding the name of the
+object: character.search("2-pinkunicorn") will try to find the second pink unicorn in the room.
+
The search method has many arguments that
+allow you to refine the search, such as by designating the location to search in or only matching
+specific typeclasses.
Sometimes you will want to find something that isn’t tied to the search methods of a character or
+account. In these cases, Evennia provides a utility module with a number of search
+functions. For example, suppose you want a command that will find and
+display all the rooms that are tagged as a ‘hangout’, for people to gather by. Here’s a simple
+Command to do this:
search_account_tag - find Accounts with a given Tag.
+
search_script_tag - find Scripts with a given Tag.
+
search_channel_tag - find Channels with a given Tag.
+
search_object_attribute - find Objects with a given Attribute.
+
search_account_attribute - find Accounts with a given Attribute.
+
search_attribute_object - this returns the actual Attribute, not the object it sits on.
+
+
+
Note: All search functions return a Django queryset which is technically a list-like
+representation of the database-query it’s about to do. Only when you convert it to a real list, loop
+over it or try to slice or access any of its contents will the datbase-lookup happen. This means you
+could yourself customize the query further if you know what you are doing (see the next section).
Evennia’s search methods should be sufficient for the vast majority of situations. But eventually
+you might find yourself trying to figure out how to get searches for unusual circumstances: Maybe
+you want to find all characters who are not in rooms tagged as hangouts and have the lycanthrope
+tag and whose names start with a vowel, but not with ‘Ab’, and only if they have 3 or more
+objects in their inventory … You could in principle use one of the earlier search methods to find
+all candidates and then loop over them with a lot of if statements in raw Python. But you can do
+this much more efficiently by querying the database directly.
+
Enter django’s querysets. A QuerySet
+is the representation of a database query and can be modified as desired. Only once one tries to
+retrieve the data of that query is it evaluated and does an actual database request. This is
+useful because it means you can modify a query as much as you want (even pass it around) and only
+hit the database once you are happy with it.
+Evennia’s search functions are themselves an even higher level wrapper around Django’s queries, and
+many search methods return querysets. That means that you could get the result from a search
+function and modify the resulting query to your own ends to further tweak what you search for.
+
Evaluated querysets can either contain objects such as Character objects, or lists of values derived
+from the objects. Queries usually use the ‘manager’ object of a class, which by convention is the
+.objects attribute of a class. For example, a query of Accounts that contain the letter ‘a’ could
+be:
The filter method of a manager takes arguments that allow you to define the query, and you can
+continue to refine the query by calling additional methods until you evaluate the queryset, causing
+the query to be executed and return a result. For example, if you have the result above, you could,
+without causing the queryset to be evaluated yet, get rid of matches that contain the letter ‘e by
+doing this:
You could also have chained .exclude directly to the end of the previous line.
+
+
Once you try to access the result, the queryset will be evaluated automatically under the hood:
+
accounts=list(queryset)# this fills list with matches
+
+foraccountinqueryset:
+ # do something with account
+
+accounts=queryset[:4]# get first four matches
+account=queryset[0]# get first match
+# etc
+
+
Although Characters, Exits, Rooms, and other children of DefaultObject all shares the same
+underlying database table, Evennia provides a shortcut to do more specific queries only for those
+typeclasses. For example, to find only Characters whose names start with ‘A’, you might do:
The higher up in the inheritance hierarchy you go the more objects will be included in these
+searches. There is one special case, if you really want to include everything from a given
+database table. You do that by searching on the database model itself. These are named ObjectDB,
+AccountDB, ScriptDB etc.
+
fromevenniaimportAccountDB
+
+# all Accounts in the database, regardless of typeclass
+all=AccountDB.objects.all()
+
+
+
+
Here are the most commonly used methods to use with the objects managers:
+
+
filter - query for a listing of objects based on search criteria. Gives empty queryset if none
+were found.
+
get - query for a single match - raises exception if none were found, or more than one was
+found.
+
all - get all instances of the particular type.
+
filter_family - like filter, but search all sub classes as well.
+
get_family - like get, but search all sub classes as well.
+
all_family - like all, but return entities of all subclasses as well.
If you pass more than one keyword argument to a query method, the query becomes an AND
+relationship. For example, if we want to find characters whose names start with “A” and are also
+werewolves (have the lycanthrope tag), we might do:
Note the syntax of the keywords in building the queryset. For example, db_location is the name of
+the database field sitting on (in this case) the Character (Object). Double underscore __ works
+like dot-notation in normal Python (it’s used since dots are not allowed in keyword names). So the
+instruction db_location__db_tags__db_key="hangout" should be read as such:
+
+
“On the Character object … (this comes from us building this queryset using the
+Character.objects manager)
+
… get the value of the db_location field … (this references a Room object, normally)
+
… on that location, get the value of the db_tags field … (this is a many-to-many field that
+can be treated like an object for this purpose. It references all tags on the location)
+
… through the db_tag manager, find all Tags having a field db_key set to the value
+“hangout”.”
+
+
This may seem a little complex at first, but this syntax will work the same for all queries. Just
+remember that all database-fields in Evennia are prefaced with db_. So even though Evennia is
+nice enough to alias the db_key field so you can normally just do char.key to get a character’s
+name, the database field is actually called db_key and the real name must be used for the purpose
+of building a query.
+
+
Don’t confuse database fields with Attributes you set via obj.db.attr='foo' or
+obj.attributes.add(). Attributes are custom database entities linked to an object. They are not
+separate fields on that object like db_key or db_location are. You can get attached Attributes
+manually through the db_attributes many-to-many field in the same way as db_tags above.
What if you want to have a query with with OR conditions or negated requirements (NOT)? Enter
+Django’s Complex Query object,
+Q. Q()
+objects take a normal django keyword query as its arguments. The special thing is that these Q
+objects can then be chained together with set operations: | for OR, & for AND, and preceded with
+~ for NOT to build a combined, complex query.
+
In our original Lycanthrope example we wanted our werewolves to have names that could start with any
+vowel except for the specific beginning “ab”.
In the above example, we construct our query our of several Q objects that each represent one part
+of the query. We iterate over the list of vowels, and add an OR condition to the query using |=
+(this is the same idea as using += which may be more familiar). Each OR condition checks that
+the name starts with one of the valid vowels. Afterwards, we add (using &=) an AND condition
+that is negated with the ~ symbol. In other words we require that any match should not start
+with the string “ab”. Note that we don’t actually hit the database until we convert the query to a
+list at the end (we didn’t need to do that either, but could just have kept the query until we
+needed to do something with the matches).
What if we wanted to filter on some condition that isn’t represented easily by a field on the
+object? Maybe we want to find rooms only containing five or more objects?
+
We could retrieve all interesting candidates and run them through a for-loop to get and count
+their .content properties. We’d then just return a list of only those objects with enough
+contents. It would look something like this (note: don’t actually do this!):
+
# probably not a good idea to do it this way
+
+fromtypeclasses.roomsimportRoom
+
+queryset=Room.objects.all()# get all Rooms
+rooms=[roomforroominquerysetiflen(room.contents)>=5]
+
+
+
+
Once the number of rooms in your game increases, this could become quite expensive. Additionally, in
+some particular contexts, like when using the web features of Evennia, you must have the result as a
+queryset in order to use it in operations, such as in Django’s admin interface when creating list
+filters.
+
Enter F objects and
+annotations. So-called F expressions allow you to do a query that looks at a value of each object
+in the database, while annotations allow you to calculate and attach a value to a query. So, let’s
+do the same example as before directly in the database:
Here we first create an annotation num_objects of type Count, which is a Django class. Note that
+use of location_set in that Count. The *_set is a back-reference automatically created by
+Django. In this case it allows you to find all objects that has the current object as location.
+Once we have those, they are counted.
+Next we filter on this annotation, using the name num_objects as something we can filter for. We
+use num_objects__gte=5 which means that num_objects should be greater than 5. This is a little
+harder to get one’s head around but much more efficient than lopping over all objects in Python.
+
What if we wanted to compare two parameters against one another in a query? For example, what if
+instead of having 5 or more objects, we only wanted objects that had a bigger inventory than they
+had tags? Here an F-object comes in handy:
Suppose you used tags to mark someone belonging an organization. Now you want to make a list and
+need to get the membership count of every organization all at once. That’s where annotations and the
+.values_list queryset method come in. Values/Values Lists are an alternate way of returning a
+queryset - instead of objects, you get a list of dicts or tuples that hold selected properties from
+the the matches. It also allows you a way to ‘group up’ queries for returning information. For
+example, to get a display about each tag per Character and the names of the tag:
This tutorial will create a simple script that will send a tweet to your already configured twitter
+account. Please see: How to connect Evennia to Twitter if you
+haven’t already done so.
+
The script could be expanded to cover a variety of statistics you might wish to tweet about
+regularly, from player deaths to how much currency is in the economy etc.
+
# evennia/typeclasses/tweet_stats.py
+
+importtwitter
+fromrandomimportrandint
+fromdjango.confimportsettings
+fromevenniaimportObjectDB
+fromevenniaimportspawner
+fromevenniaimportlogger
+fromevenniaimportDefaultScript
+
+classTweetStats(DefaultScript):
+ """
+ This implements the tweeting of stats to a registered twitter account
+ """
+
+ # standard Script hooks
+
+ defat_script_creation(self):
+ "Called when script is first created"
+
+ self.key="tweet_stats"
+ self.desc="Tweets interesting stats about the game"
+ self.interval=86400# 1 day timeout
+ self.start_delay=False
+
+ defat_repeat(self):
+ """
+ This is called every self.interval seconds to tweet interesting stats about the game.
+ """
+
+ api=twitter.Api(consumer_key='consumer_key',
+ consumer_secret='consumer_secret',
+ access_token_key='access_token_key',
+ access_token_secret='access_token_secret')
+
+ number_tweet_outputs=2
+
+ tweet_output=randint(1,number_tweet_outputs)
+
+ iftweet_output==1:
+ ##Game Chars, Rooms, Objects taken from @stats command
+ nobjs=ObjectDB.objects.count()
+ base_char_typeclass=settings.BASE_CHARACTER_TYPECLASS
+ nchars=ObjectDB.objects.filter(db_typeclass_path=base_char_typeclass).count()
+ nrooms=ObjectDB.objects.filter(db_location__isnull=True).exclude(db_typeclass_path=bas
+e_char_typeclass).count()
+ nexits=ObjectDB.objects.filter(db_location__isnull=False,
+db_destination__isnull=False).count()
+ nother=nobjs-nchars-nrooms-nexits
+ tweet="Chars: %s, Rooms: %s, Objects: %s"%(nchars,nrooms,nother)
+ else:
+ iftweet_output==2:##Number of prototypes and 3 random keys - taken from @spawn
+command
+ prototypes=spawner.spawn(return_prototypes=True)
+
+ keys=prototypes.keys()
+ nprots=len(prototypes)
+ tweet="Prototype Count: %s Random Keys: "%nprots
+
+ tweet+=" %s"%keys[randint(0,len(keys)-1)]
+ forxinrange(0,2):##tweet 3
+ tweet+=", %s"%keys[randint(0,len(keys)-1)]
+ # post the tweet
+ try:
+ response=api.PostUpdate(tweet)
+ except:
+ logger.log_trace("Tweet Error: When attempting to tweet %s"%tweet)
+
+
+
In the at_script_creation method, we configure the script to fire immediately (useful for testing)
+and setup the delay (1 day) as well as script information seen when you use @scripts
+
In the at_repeat method (which is called immediately and then at interval seconds later) we setup
+the Twitter API (just like in the initial configuration of twitter). numberTweetOutputs is used to
+show how many different types of outputs we have (in this case 2). We then build the tweet based on
+randomly choosing between these outputs.
+
+
Shows the number of Player Characters, Rooms and Other/Objects
+
Shows the number of prototypes currently in the game and then selects 3 random keys to show
+
+
Scripts Information will show you how to add it as a Global script, however, for testing
+it may be useful to start/stop it quickly from within the game. Assuming that you create the file
+as mygame/typeclasses/tweet_stats.py it can be started by using the following command
This tutorial explains how you can create vehicles that can move around in your world. The tutorial
+will explain how to create a train, but this can be equally applied to create other kind of vehicles
+(cars, planes, boats, spaceships, submarines, …).
Objects in Evennia have an interesting property: you can put any object inside another object. This
+is most obvious in rooms: a room in Evennia is just like any other game object (except rooms tend to
+not themselves be inside anything else).
+
Our train will be similar: it will be an object that other objects can get inside. We then simply
+move the Train, which brings along everyone inside it.
The first step we need to do is create our train object, including a new typeclass. To do this,
+create a new file, for instance in mygame/typeclasses/train.py with the following content:
Using the @telcommand like shown above is obviously not what we want. @tel is an admin command
+and normal players will thus never be able to enter the train! It is also not really a good idea to
+use Exits to get in and out of the train - Exits are (at least by default) objects
+too. They point to a specific destination. If we put an Exit in this room leading inside the train
+it would stay here when the train moved away (still leading into the train like a magic portal!). In
+the same way, if we put an Exit object inside the train, it would always point back to this room,
+regardless of where the Train has moved. Now, one could define custom Exit types that move with
+the train or change their destination in the right way - but this seems to be a pretty cumbersome
+solution.
+
What we will do instead is to create some new commands: one for entering the train and
+another for leaving it again. These will be stored on the train object and will thus be made
+available to whomever is either inside it or in the same room as the train.
+
Let’s create a new command module as mygame/commands/train.py:
+
# mygame/commands/train.py
+
+fromevenniaimportCommand,CmdSet
+
+classCmdEnterTrain(Command):
+ """
+ entering the train
+
+ Usage:
+ enter train
+
+ This will be available to players in the same location
+ as the train and allows them to embark.
+ """
+
+ key="enter train"
+ locks="cmd:all()"
+
+ deffunc(self):
+ train=self.obj
+ self.caller.msg("You board the train.")
+ self.caller.move_to(train)
+
+
+classCmdLeaveTrain(Command):
+ """
+ leaving the train
+
+ Usage:
+ leave train
+
+ This will be available to everyone inside the
+ train. It allows them to exit to the train's
+ current location.
+ """
+
+ key="leave train"
+ locks="cmd:all()"
+
+ deffunc(self):
+ train=self.obj
+ parent=train.location
+ self.caller.move_to(parent)
+
+
+classCmdSetTrain(CmdSet):
+
+ defat_cmdset_creation(self):
+ self.add(CmdEnterTrain())
+ self.add(CmdLeaveTrain())
+
+
+
Note that while this seems like a lot of text, the majority of lines here are taken up by
+documentation.
+
These commands are work in a pretty straightforward way: CmdEnterTrain moves the location of the
+player to inside the train and CmdLeaveTrain does the opposite: it moves the player back to the
+current location of the train (back outside to its current location). We stacked them in a
+cmdsetCmdSetTrain so they can be used.
+
To make the commands work we need to add this cmdset to our train typeclass:
Note the switches used with the @typeclass command: The /force switch is necessary to assign our
+object the same typeclass we already have. The /reset re-triggers the typeclass’
+at_object_creation() hook (which is otherwise only called the very first an instance is created).
+As seen above, when this hook is called on our train, our new cmdset will be loaded.
If you have played around a bit, you’ve probably figured out that you can use leavetrain when
+outside the train and entertrain when inside. This doesn’t make any sense … so let’s go ahead
+and fix that. We need to tell Evennia that you can not enter the train when you’re already inside
+or leave the train when you’re outside. One solution to this is locks: we will lock down
+the commands so that they can only be called if the player is at the correct location.
+
Right now commands defaults to the lock cmd:all(). The cmd lock type in combination with the
+all() lock function means that everyone can run those commands as long as they are in the same
+room as the train or inside the train. We’re going to change this to check the location of the
+player and only allow access if they are inside the train.
+
First of all we need to create a new lock function. Evennia comes with many lock functions built-in
+already, but none that we can use for locking a command in this particular case. Create a new entry
+in mygame/server/conf/lockfuncs.py:
+
+# file mygame/server/conf/lockfuncs.py
+
+defcmdinside(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage: cmdinside()
+ Used to lock commands and only allows access if the command
+ is defined on an object which accessing_obj is inside of.
+ """
+ returnaccessed_obj.obj==accessing_obj.location
+
+
+
+
If you didn’t know, Evennia is by default set up to use all functions in this module as lock
+functions (there is a setting variable that points to it).
+
Our new lock function, cmdinside, is to be used by Commands. The accessed_obj is the Command
+object (in our case this will be CmdEnterTrain and CmdLeaveTrain) — Every command has an obj
+property: this is the the object on which the command “sits”. Since we added those commands to our
+train object, the .obj property will be set to the train object. Conversely, accessing_obj is
+the object that called the command: in our case it’s the Character trying to enter or leave the
+train.
+
What this function does is to check that the player’s location is the same as the train object. If
+it is, it means the player is inside the train. Otherwise it means the player is somewhere else and
+the check will fail.
+
The next step is to actually use this new lock function to create a lock of type cmd:
Notice how we use the not here so that we can use the same cmdinside to check if we are inside
+and outside, without having to create two separate lock functions. After a @reload our commands
+should be locked down appropriately and you should only be able to use them at the right places.
+
+
Note: If you’re logged in as the super user (user #1) then this lock will not work: the super
+user ignores lock functions. In order to use this functionality you need to @quell first.
Now that we can enter and leave the train correctly, it’s time to make it move. There are different
+things we need to consider for this:
+
+
Who can control your vehicle? The first player to enter it, only players that have a certain
+“drive” skill, automatically?
+
Where should it go? Can the player steer the vehicle to go somewhere else or will it always follow
+the same route?
+
+
For our example train we’re going to go with automatic movement through a predefined route (its
+track). The train will stop for a bit at the start and end of the route to allow players to enter
+and leave it.
+
Go ahead and create some rooms for our train. Make a list of the room ids along the route (using the
+@ex command).
+
@dig/telSouthstation
+@ex# note the id of the station
+@tunnel/teln=Followingarailroad
+@ex# note the id of the track
+@tunnel/teln=Followingarailroad
+...
+@tunnel/teln=NorthStation
+
+
+
Put the train onto the tracks:
+
@telsouthstation
+@teltrain=here
+
+
+
Next we will tell the train how to move and which route to take.
+
# file typeclasses/train.py
+
+fromevenniaimportDefaultObject,search_object
+
+fromcommands.trainimportCmdSetTrain
+
+classTrainObject(DefaultObject):
+
+ defat_object_creation(self):
+ self.cmdset.add_default(CmdSetTrain)
+ self.db.driving=False
+ # The direction our train is driving (1 for forward, -1 for backwards)
+ self.db.direction=1
+ # The rooms our train will pass through (change to fit your game)
+ self.db.rooms=["#2","#47","#50","#53","#56","#59"]
+
+ defstart_driving(self):
+ self.db.driving=True
+
+ defstop_driving(self):
+ self.db.driving=False
+
+ defgoto_next_room(self):
+ currentroom=self.location.dbref
+ idx=self.db.rooms.index(currentroom)+self.db.direction
+
+ ifidx<0oridx>=len(self.db.rooms):
+ # We reached the end of our path
+ self.stop_driving()
+ # Reverse the direction of the train
+ self.db.direction*=-1
+ else:
+ roomref=self.db.rooms[idx]
+ room=search_object(roomref)[0]
+ self.move_to(room)
+ self.msg_contents("The train is moving forward to %s."%(room.name,))
+
+
+
We added a lot of code here. Since we changed the at_object_creation to add in variables we will
+have to reset our train object like earlier (using the @typeclass/force/reset command).
+
We are keeping track of a few different things now: whether the train is moving or standing still,
+which direction the train is heading to and what rooms the train will pass through.
+
We also added some methods: one to start moving the train, another to stop and a third that actually
+moves the train to the next room in the list. Or makes it stop driving if it reaches the last stop.
+
Let’s try it out, using @py to call the new train functionality:
If we wanted full control of the train we could now just add a command to step it along the track
+when desired. We want the train to move on its own though, without us having to force it by manually
+calling the goto_next_room method.
+
To do this we will create two scripts: one script that runs when the train has stopped at
+a station and is responsible for starting the train again after a while. The other script will take
+care of the driving.
+
Let’s make a new file in mygame/typeclasses/trainscript.py
Those scripts work as a state system: when the train is stopped, it waits for 30 seconds and then
+starts again. When the train is driving, it moves to the next room every second. The train is always
+in one of those two states - both scripts take care of adding the other one once they are done.
+
As a last step we need to link the stopped-state script to our train, reload the game and reset our
+train again., and we’re ready to ride it around!
This train is very basic and still has some flaws. Some more things to do:
+
+
Make it look like a train.
+
Make it impossible to exit and enter the train mid-ride. This could be made by having the
+enter/exit commands check so the train is not moving before allowing the caller to proceed.
+
Have train conductor commands that can override the automatic start/stop.
+
Allow for in-between stops between the start- and end station
+
Have a rail road track instead of hard-coding the rooms in the train object. This could for
+example be a custom Exit only traversable by trains. The train will follow the
+track. Some track segments can split to lead to two different rooms and a player can switch the
+direction to which room it goes.
The Tutorial World is a small and functioning MUD-style game world. It is intended to be
+deconstructed and used as a way to learn Evennia. The game consists of a single-player quest and
+has some 20 rooms that you can explore as you seek to discover the whereabouts of a mythical weapon.
+
The source code is fully documented. You can find the whole thing in
+evennia/contrib/tutorial_world/.
+
Some features exemplified by the tutorial world:
+
+
Tutorial command, giving “behind-the-scenes” help for every room and some of the special objects
+
Rooms with custom return_appearance to show details.
+
Hidden exits
+
Objects with multiple custom interactions
+
Large-area rooms
+
Outdoor weather rooms
+
Dark room, needing light source
+
Puzzle object
+
Multi-room puzzle
+
Aggressive mobile with roam, pursue and battle state-engine AI
The tutorial world consists of a few modules in evennia/contrib/tutorial_world/ containing custom
+Typeclasses for rooms and objects and associated Commands.
+
These reusable bits and pieces are then put together into a functioning game area (“world” is maybe
+too big a word for such a small zone) using a batch script called build.ev. To
+install, log into the server as the superuser (user #1) and run:
+
@batchcommand tutorial_world.build
+
+
+
The world will be built (this might take a while, so don’t rerun the command even if it seems the
+system has frozen). After finishing you will end up back in Limbo with a new exit called tutorial.
+
An alternative is
+
@batchcommand/interactive tutorial_world.build
+
+
+
with the /interactive switch you are able to step through the building process at your own pace to
+see what happens in detail.
Non-superusers entering the tutorial will be auto-quelled so they play with their Character’s
+permission. As superuser you will not be auto-quelled, but it’s recommended that you still quell
+manually to play the tutorial “correctly”. The reason for this is that many game systems ignore the
+presence of a superuser and will thus not work as normal.
+
Use unquell if you want to get back your main account-level permissions to examine things under
+the hood. When you exit the tutorial (either by winning or using the abort/giveup command) you
+will automatically be unquelled.
To get into the mood of this miniature quest, imagine you are an adventurer out to find fame and
+fortune. You have heard rumours of an old castle ruin by the coast. In its depth a warrior princess
+was buried together with her powerful magical weapon - a valuable prize, if it’s true. Of course
+this is a chance to adventure that you cannot turn down!
+
You reach the ocean in the midst of a raging thunderstorm. With wind and rain screaming in your
+face you stand where the moor meets the sea along a high, rocky coast …
+
+
Look at everything.
+
Some objects are interactive in more than one way. Use the normal help command to get a feel for
+which commands are available at any given time. (use the command tutorial to get insight behind
+the scenes of the tutorial).
+
In order to fight, you need to first find some type of weapon.
+
slash is a normal attack
+
stab launches an attack that makes more damage but has a lower chance to hit.
+
defend will lower the chance to taking damage on your enemy’s next attack.
+
You can run from a fight that feels too deadly. Expect to be chased though.
Uninstalling the tutorial world basically means deleting all the rooms and objects it consists of.
+First, move out of the tutorial area.
+
@find tut#01
+ @find tut#16
+
+
+
This should locate the first and last rooms created by build.ev - Intro and Outro. If you
+installed normally, everything created between these two numbers should be part of the tutorial.
+Note their dbref numbers, for example 5 and 80. Next we just delete all objects in that range:
+
@del 5-80
+
+
+
You will see some errors since some objects are auto-deleted and so cannot be found when the delete
+mechanism gets to them. That’s fine. You should have removed the tutorial completely once the
+command finishes.
When reading and learning from the code, keep in mind that Tutorial World was created with a very
+specific goal: to install easily and to not permanently modify the rest of the server. It therefore
+goes to some length to use only temporary solutions and to clean up after
+itself.
This tutorial lets you code a small but complete and functioning MUSH-like game in Evennia. A
+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 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. +setpower4) 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. +createNPCJenkins) 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.
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.
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:
+
# mygame/typeclasses/characters.py
+
+fromevenniaimportDefaultCharacter
+
+classCharacter(DefaultCharacter):
+ """
+ [...]
+ """
+ defat_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 Attributespower 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 @reloaded 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.
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 to set the “Power” on the Character.
+
A chargen CmdSet to hold this command. Lets call it ChargenCmdset.
+
A custom ChargenRoom type that makes this set of commands available to players in such rooms.
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 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:
+
# end of command.py
+fromevenniaimportCommand# just for clarity; already imported above
+
+classCmdSetPower(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"
+
+ deffunc(self):
+ "This performs the actual command"
+ errmsg="You must supply a number between 1 and 10."
+ ifnotself.args:
+ self.caller.msg(errmsg)
+ return
+ try:
+ power=int(self.args)
+ exceptValueError:
+ self.caller.msg(errmsg)
+ return
+ ifnot(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 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:
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):
+
# end of default_cmdsets.py
+
+classChargenCmdset(CmdSet):
+ """
+ This cmdset it used in character generation areas.
+ """
+ key="Chargen"
+ defat_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.
We will create a simple Room typeclass to act as a template for all our Chargen areas. Edit
+mygame/typeclasses/rooms.py next:
+
fromcommands.default_cmdsetsimportChargenCmdset
+
+# ...
+# down at the end of rooms.py
+
+classChargenRoom(Room):
+ """
+ This room class is used by character-generation rooms. It makes
+ the ChargenCmdset available.
+ """
+ defat_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 and Commands, see the respective
+links.
First, make sure you have @reloaded the server (or use evenniareload 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;charactergeneration.
+
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 exme (as a privileged
+user) to check so the PowerAttribute 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.
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 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:
+
importrandom
+
+# ...
+
+classCmdAttack(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"
+
+ deffunc(self):
+ "Calculate the random score between 1-10*Power"
+ caller=self.caller
+ power=caller.db.power
+ ifnotpower:
+ # 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:
+
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.
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).
+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 returnappearance is found in evennia.DefaultCharacter (or online
+here). 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:
+
classCharacter(DefaultCharacter):
+ """
+ [...]
+ """
+ defat_object_creation(self):
+ "This is called when object is first created, only."
+ self.db.power=1
+ self.db.combat_score=1
+
+ defreturn_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"intext:
+ # 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
+ returntext
+
+
+
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 Rooms 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.
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.
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:
+
fromevenniaimportcreate_object
+
+classCmdCreateNPC(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"
+
+ deffunc(self):
+ "creates the object and names it"
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("Usage: +createNPC <name>")
+ return
+ ifnotcaller.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” (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 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.
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 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.
This is a slightly more complex command. It goes at the end of your command.py file as before.
+
classCmdEditNPC(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"
+
+ defparse(self):
+ "We need to do some parsing here"
+ args=self.args
+ propname,propval=None,None
+ if"="inargs:
+ args,propval=[part.strip()forpartinargs.rsplit("=",1)]
+ if"/"inargs:
+ args,propname=[part.strip()forpartinargs.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=propvalifpropnameelseNone
+
+ deffunc(self):
+ "do the editing"
+
+ allowed_propnames=("power","attribute1","attribute2")
+
+ caller=self.caller
+ ifnotself.argsornotself.name:
+ caller.msg("Usage: +editnpc name[/propname][=propval]")
+ return
+ npc=caller.search(self.name)
+ ifnotnpc:
+ return
+ ifnotnpc.access(caller,"edit"):
+ caller.msg("You cannot change this NPC.")
+ return
+ ifnotself.propname:
+ # this means we just list the values
+ output=f"Properties of {npc.key}:"
+ forpropnameinallowed_propnames:
+ output+=f"\n{propname} = {npc.attributes.get(propname,default='N/A')}"
+ caller.msg(output)
+ elifself.propnamenotinallowed_propnames:
+ caller.msg(f"You may only change {', '.join(allowed_propnames)}.")
+ elifself.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(f"Set {npc.key}'s property {self.propname} to {self.propval}")
+ else:
+ # propname set, but not propval - show current value
+ caller.msg(f"{npc.key} has property {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.
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:
+
classCmdNPC(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"
+
+ defparse(self):
+ "Simple split of the = sign"
+ name,cmdname=None,None
+ if"="inself.args:
+ name,cmdname=self.args.rsplit("=",1)
+ name=name.strip()
+ cmdname=cmdname.strip()
+ self.name,self.cmdname=name,cmdname
+
+ deffunc(self):
+ "Run the command"
+ caller=self.caller
+ ifnotself.cmdname:
+ caller.msg("Usage: +npc <name> = <command>")
+ return
+ npc=caller.search(self.name)
+ ifnotnpc:
+ return
+ ifnotnpc.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(f"You told {npc.key} to do '{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:
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 @icmynpc 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.
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.
Before continuing to read these tutorials (and especially before you start to code or build your
+game in earnest) it’s strongly recommended that you read the
+Evennia coding introduction as well as the Planning your own game pages first.
+
Please note that it’s not within the scope of our tutorials to teach you basic Python. If you are
+new to the language, expect to have to look up concepts you are unfamiliar with. Usually a quick
+internet search will give you all info you need. Furthermore, our tutorials tend to focus on
+implementation and concepts. As such they give only brief explanations to use Evennia features while
+providing ample links to the relevant detailed documentation.
Introduction: The Tutorial World - this introduces the full (if
+small) solo-adventure game that comes with the Evennia distribution. It is useful both as an example
+of building and of coding.
Python tutorials for beginners -
+external link with tutorials for those not familiar with coding in general or Python in particular.
+
Tutorial: Version Control - use GIT to organize your code both for your own
+game project and for contributing to Evennia.
+
MIT offers free courses in many subjects. Their [Introduction to Computer Science and Programming](https://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-00sc-
+introduction-to-computer-science-and-programming-spring-2011/) uses Python as its language of
+choice. Longer path, but more in-depth. Definitely worth a look.
This section contains tutorials linked with contribs. These contribs can be used in your game, but
+you’ll need to install them explicitly. They add common features that can earn you time in
+implementation.
Typeclasses form the core of Evennia data storage. It allows Evennia to represent any number of
+different game entities as Python classes, without having to modify the database schema for every
+new type.
+
In Evennia the most important game entities, Accounts, Objects,
+Scripts and Channels are all Python classes inheriting, at
+varying distance, from evennia.typeclasses.models.TypedObject. In the documentation we refer to
+these objects as being “typeclassed” or even “being a typeclass”.
+
This is how the inheritance looks for the typeclasses in Evennia:
Level 1 above is the “database model” level. This describes the database tables and fields
+(this is technically a Django model).
+
Level 2 is where we find Evennia’s default implementations of the various game entities, on
+top of the database. These classes define all the hook methods that Evennia calls in various
+situations. DefaultObject is a little special since it’s the parent for DefaultCharacter,
+DefaultRoom and DefaultExit. They are all grouped under level 2 because they all represents
+defaults to build from.
+
Level 3, finally, holds empty template classes created in your game directory. This is the
+level you are meant to modify and tweak as you please, overloading the defaults as befits your game.
+The templates inherit directly from their defaults, so Object inherits from DefaultObject and
+Room inherits from DefaultRoom.
+
+
The typeclass/list command will provide a list of all typeclasses known to
+Evennia. This can be useful for getting a feel for what is available. Note
+however that if you add a new module with a class in it but do not import that
+module from anywhere, the typeclass/list will not find it. To make it known
+to Evennia you must import that module from somewhere.
All Evennia classes inheriting from class in the table above share one important feature and two
+important limitations. This is why we don’t simply call them “classes” but “typeclasses”.
+
+
A typeclass can save itself to the database. This means that some properties (actually not that
+many) on the class actually represents database fields and can only hold very specific data types.
+This is detailed below.
+
Due to its connection to the database, the typeclass’ name must be unique across the entire
+server namespace. That is, there must never be two same-named classes defined anywhere. So the below
+code would give an error (since DefaultObject is now globally found both in this module and in the
+default library):
A typeclass’ __init__ method should normally not be overloaded. This has mostly to do with the
+fact that the __init__ method is not called in a predictable way. Instead Evennia suggest you use
+the at_*_creation hooks (like at_object_creation for Objects) for setting things the very first
+time the typeclass is saved to the database or the at_init hook which is called every time the
+object is cached to memory. If you know what you are doing and want to use __init__, it must
+both accept arbitrary keyword arguments and use super to call its parent::
+
def__init__(self,**kwargs):
+ # my content
+ super().__init__(**kwargs)
+ # my content
+
+
+
+
+
Apart from this, a typeclass works like any normal Python class and you can
+treat it as such.
It’s easy to work with Typeclasses. Either you use an existing typeclass or you create a new Python
+class inheriting from an existing typeclass. Here is an example of creating a new type of Object:
+
fromevenniaimportDefaultObject
+
+ classFurniture(DefaultObject):
+ # this defines what 'furniture' is, like
+ # storing who sits on it or something.
+ pass
+
+
+
+
You can now create a new Furniture object in two ways. First (and usually not the most
+convenient) way is to create an instance of the class and then save it manually to the database:
+
chair=Furniture(db_key="Chair")
+chair.save()
+
+
+
+
To use this you must give the database field names as keywords to the call. Which are available
+depends on the entity you are creating, but all start with db_* in Evennia. This is a method you
+may be familiar with if you know Django from before.
+
It is recommended that you instead use the create_* functions to create typeclassed entities:
+
fromevenniaimportcreate_object
+
+chair=create_object(Furniture,key="Chair")
+# or (if your typeclass is in a module furniture.py)
+chair=create_object("furniture.Furniture",key="Chair")
+
+
+
The create_object (create_account, create_script etc) takes the typeclass as its first
+argument; this can both be the actual class or the python path to the typeclass as found under your
+game directory. So if your Furniture typeclass sits in mygame/typeclasses/furniture.py, you
+could point to it as typeclasses.furniture.Furniture. Since Evennia will itself look in
+mygame/typeclasses, you can shorten this even further to just furniture.Furniture. The create-
+functions take a lot of extra keywords allowing you to set things like Attributes and
+Tags all in one go. These keywords don’t use the db_* prefix. This will also automatically
+save the new instance to the database, so you don’t need to call save() explicitly.
An example of a database field is db_key. This stores the “name” of the entity you are modifying
+and can thus only hold a string. This is one way of making sure to update the db_key:
That is, we change the chair object to have the db_key “Table”, then save this to the database.
+However, you almost never do things this way; Evennia defines property wrappers for all the database
+fields. These are named the same as the field, but without the db_ part:
The key wrapper is not only shorter to write, it will make sure to save the field for you, and
+does so more efficiently by levering sql update mechanics under the hood. So whereas it is good to
+be aware that the field is named db_key you should use key as much as you can.
+
Each typeclass entity has some unique fields relevant to that type. But all also share the
+following fields (the wrapper name without db_ is given):
+
+
key (str): The main identifier for the entity, like “Rose”, “myscript” or “Paul”. name is an
+alias.
+
date_created (datetime): Time stamp when this object was created.
+
typeclass_path (str): A python path pointing to the location of this (type)class
+
+
There is one special field that doesn’t use the db_ prefix (it’s defined by Django):
+
+
id (int): the database id (database ref) of the object. This is an ever-increasing, unique
+integer. It can also be accessed as dbid (database ID) or pk (primary key). The dbref property
+returns the string form “#id”.
+
+
The typeclassed entity has several common handlers:
+
+
tags - the TagHandler that handles tagging. Use tags.add() , tags.get() etc.
+
locks - the LockHandler that manages access restrictions. Use locks.add(),
+locks.get() etc.
+
attributes - the AttributeHandler that manages Attributes on the object. Use
+attributes.add()
+etc.
+
db (DataBase) - a shortcut property to the AttributeHandler; allowing obj.db.attrname=value
ndb (NotDataBase) - a shortcut property to the Non-peristent AttributeHandler. Allows
+obj.ndb.attrname=value
+
+
Each of the typeclassed entities then extend this list with their own properties. Go to the
+respective pages for Objects, Scripts, Accounts and
+Channels for more info. It’s also recommended that you explore the available
+entities using Evennia’s flat API to explore which properties and methods they have
+available.
The way to customize typeclasses is usually to overload hook methods on them. Hooks are methods
+that Evennia call in various situations. An example is the at_object_creation hook on Objects,
+which is only called once, the very first time this object is saved to the database. Other examples
+are the at_login hook of Accounts and the at_repeat hook of Scripts.
Most of the time you search for objects in the database by using convenience methods like the
+caller.search() of Commands or the search functions like evennia.search_objects.
+
You can however also query for them directly using Django’s query
+language. This makes use of a database
+manager that sits on all typeclasses, named objects. This manager holds methods that allow
+database searches against that particular type of object (this is the way Django normally works
+too). When using Django queries, you need to use the full field names (like db_key) to search:
+
matches=Furniture.objects.get(db_key="Chair")
+
+
+
+
It is important that this will only find objects inheriting directly from Furniture in your
+database. If there was a subclass of Furniture named Sitables you would not find any chairs
+derived from Sitables with this query (this is not a Django feature but special to Evennia). To
+find objects from subclasses Evennia instead makes the get_family and filter_family query
+methods available:
+
# search for all furnitures and subclasses of furnitures
+# whose names starts with "Chair"
+matches=Furniture.objects.filter_family(db_key__startswith="Chair")
+
+
+
+
To make sure to search, say, all Scriptsregardless of typeclass, you need to query from the
+database model itself. So for Objects, this would be ObjectDB in the diagram above. Here’s an
+example for Scripts:
When querying from the database model parent you don’t need to use filter_family or get_family -
+you will always query all children on the database model.
If you already have created instances of Typeclasses, you can modify the Python code at any time -
+due to how Python inheritance works your changes will automatically be applied to all children once
+you have reloaded the server.
+
However, database-saved data, like db_* fields, Attributes, Tags etc, are
+not themselves embedded into the class and will not be updated automatically. This you need to
+manage yourself, by searching for all relevant objects and updating or adding the data:
+
# add a worth Attribute to all existing Furniture
+forobjinFurniture.objects.all():
+ # this will loop over all Furniture instances
+ obj.db.worth=100
+
+
+
A common use case is putting all Attributes in the at_*_creation hook of the entity, such as
+at_object_creation for Objects. This is called every time an object is created - and only then.
+This is usually what you want but it does mean already existing objects won’t get updated if you
+change the contents of at_object_creation later. You can fix this in a similar way as above
+(manually setting each Attribute) or with something like this:
+
# Re-run at_object_creation only on those objects not having the new Attribute
+forobjinFurniture.objects.all():
+ ifnotobj.db.worth:
+ obj.at_object_creation()
+
+
+
The above examples can be run in the command prompt created by evenniashell. You could also run
+it all in-game using @py. That however requires you to put the code (including imports) as one
+single line using ; and list
+comprehensions, like this (ignore the
+line break, that’s only for readability in the wiki):
If you want to swap an already existing typeclass, there are two ways to do so: From in-game and via
+code. From inside the game you can use the default @typeclass command:
+
@typeclassobjname=path.to.new.typeclass
+
+
+
There are two important switches to this command:
+
+
/reset - This will purge all existing Attributes on the object and re-run the creation hook
+(like at_object_creation for Objects). This assures you get an object which is purely of this new
+class.
+
/force - This is required if you are changing the class to be the same class the object
+already has - it’s a safety check to avoid user errors. This is usually used together with /reset
+to re-run the creation hook on an existing class.
+
+
In code you instead use the swap_typeclass method which you can find on all typeclassed entities:
Technically, typeclasses are Django proxy
+models. The only database
+models that are “real” in the typeclass system (that is, are represented by actual tables in the
+database) are AccountDB, ObjectDB, ScriptDB and ChannelDB (there are also
+Attributes and Tags but they are not typeclasses themselves). All the
+subclasses of them are “proxies”, extending them with Python code without actually modifying the
+database layout.
+
Evennia modifies Django’s proxy model in various ways to allow them to work without any boiler plate
+(for example you don’t need to set the Django “proxy” property in the model Meta subclass, Evennia
+handles this for you using metaclasses). Evennia also makes sure you can query subclasses as well as
+patches django to allow multiple inheritance from the same base class.
Evennia uses the idmapper to cache its typeclasses (Django proxy models) in memory. The idmapper
+allows things like on-object handlers and properties to be stored on typeclass instances and to not
+get lost as long as the server is running (they will only be cleared on a Server reload). Django
+does not work like this by default; by default every time you search for an object in the database
+you’ll get a different instance of that object back and anything you stored on it that was not in
+the database would be lost. The bottom line is that Evennia’s Typeclass instances subside in memory
+a lot longer than vanilla Django model instance do.
+
There is one caveat to consider with this, and that relates to [making your own models](New-
+Models): Foreign relationships to typeclasses are cached by Django and that means that if you were
+to change an object in a foreign relationship via some other means than via that relationship, the
+object seeing the relationship may not reliably update but will still see its old cached version.
+Due to typeclasses staying so long in memory, stale caches of such relationships could be more
+visible than common in Django. See the closed issue #1098 and its
+comments for examples and solutions.
This tutorial aims at dispelling confusions regarding the use of color tags within Evennia.
+
Correct understanding of this topic requires having read the TextTags page and learned
+Evennia’s color tags. Here we’ll explain by examples the reasons behind the unexpected (or
+apparently incoherent) behaviors of some color tags, as mentioned en passant in the
+TextTags page.
+
All you’ll need for this tutorial is access to a running instance of Evennia via a color-enabled
+client. The examples provided are just commands that you can type in your client.
All modern MUD clients support colors; nevertheless, the standards to which all clients abide dates
+back to old day of terminals, and when it comes to colors we are dealing with ANSI and Xterm256
+standards.
+
Evennia handles transparently, behind the scenes, all the code required to enforce these
+standards—so, if a user connects with a client which doesn’t support colors, or supports only ANSI
+(16 colors), Evennia will take all due steps to ensure that the output will be adjusted to look
+right at the client side.
+
As for you, the developer, all you need to care about is knowing how to correctly use the color tags
+within your MUD. Most likely, you’ll be adding colors to help pages, descriptions, automatically
+generated text, etc.
+
You are free to mix together ANSI and Xterm256 color tags, but you should be aware of a few
+pitfalls. ANSI and Xterm256 coexist without conflicts in Evennia, but in many ways they don’t «see»
+each other: ANSI-specific color tags will have no effect on Xterm-defined colors, as we shall see
+here.
ANSI has a set of 16 colors, to be more precise: ANSI has 8 basic colors which come in dark and
+bright flavours—with dark being normal. The colors are: red, green, yellow, blue, magenta,
+cyan, white and black. White in its dark version is usually referred to as gray, and black in its
+bright version as darkgray. Here, for sake of simplicity they’ll be referred to as dark and bright:
+bright/dark black, bright/dark white.
+
The default colors of MUD clients is normal (dark) white on normal black (ie: gray on black).
+
It’s important to grasp that in the ANSI standard bright colors apply only to text (foreground), not
+to background. Evennia allows to bypass this limitation via Xterm256, but doing so will impact the
+behavior of ANSI tags, as we shall see.
+
Also, it’s important to remember that the 16 ANSI colors are a convention, and the final user can
+always customize their appearance—he might decide to have green show as red, and dark green as blue,
+etc.
The 16 colors of ANSI should be more than enough to handle simple coloring of text. But when an
+author wants to be sure that a given color will show as he intended it, she might choose to rely on
+Xterm256 colors.
+
Xterm256 doesn’t rely on a palette of named colors, it instead represent colors by their values. So,
+a red color could be |[500 (bright and pure red), or |[300 (darker red), and so on.
NOTE: for ease of reading, the examples contain extra white spaces after the
+color tags (eg: |ggreen|bblue ). This is done only so that it’s easier
+to see the tags separated from their context; it wouldn’t be good practice
+in real-life coding.
+
+
Let’s proceed by examples. In your MUD client type:
+
say Normal |* Negative
+
+
+
Evennia should output the word “Normal” normally (ie: gray on black) and “Negative” in reversed
+colors (ie: black on gray).
+
This is pretty straight forward, the |* ANSI invert tag switches between foreground and
+background—from now on, FG and BG shorthands will be used to refer to foreground and
+background.
+
But take mental note of this: |* has switched dark white and dark black.
+
Now try this:
+
say |w Bright white FG |* Negative
+
+
+
You’ll notice that the word “Negative” is not black on white, it’s darkgray on gray. Why is this?
+Shouldn’t it be black text on a white BG? Two things are happening here.
+
As mentioned, ANSI has 8 base colors, the dark ones. The bright ones are achieved by means of
+highlighting the base/dark/normal colors, and they only apply to FG.
+
What happened here is that when we set the bright white FG with |w, Evennia translated this into
+the ANSI sequence of Highlight On + White FG. In terms of Evennia’s color tags, it’s as if we typed:
+
say |h|!W Bright white FG |* Negative
+
+
+
Furthermore, the Highlight-On property (which only works for BG!) is preserved after the FG/BG
+switch, this being the reason why we see black as darkgray: highlighting makes it bright black
+(ie: darkgray).
+
As for the BG being also grey, that is normal—ie: you are seeing normal white (ie: dark white =
+gray). Remember that since there are no bright BG colors, the ANSI |* tag will transpose any FG
+color in its normal/dark version. So here the FG’s bright white became dark white in the BG! In
+reality, it was always normal/dark white, except that in the FG is seen as bright because of the
+highlight tag behind the scenes.
+
Let’s try the same thing with some color:
+
say |m |[G Bright Magenta on Dark Green |* Negative
+
+
+
Again, the BG stays dark because of ANSI rules, and the FG stays bright because of the implicit |h
+in |m.
+
Now, let’s see what happens if we set a bright BG and then invert—yes, Evennia kindly allows us to
+do it, even if it’s not within ANSI expectations.
+
say |[b Dark White on Bright Blue |* Negative
+
+
+
Before color inversion, the BG does show in bright blue, and after inversion (as expected) it’s
+dark white (gray). The bright blue of the BG survived the inversion and gave us a bright blue FG.
+This behavior is tricky though, and not as simple as it might look.
+
If the inversion were to be pure ANSI, the bright blue would have been accounted just as normal
+blue, and should have converted to normal blue in the FG (after all, there was no highlighting on).
+The fact is that in reality this color is not bright blue at all, it just an Xterm version of it!
+
To demonstrate this, type:
+
say |[b Dark White on Bright Blue |* Negative |H un-bright
+
+
+
The |H Highlight-Off tag should have turned dark blue the last word; but it didn’t because it
+couldn’t: in order to enforce the non-ANSI bright BG Evennia turned to Xterm, and Xterm entities are
+not affected by ANSI tags!
+
So, we are getting at the heart of all confusions and possible odd-behaviors pertaining color tags
+in Evennia: apart from Evennia’s translations from- and to- ANSI/Xterm, the two systems are
+independent and transparent to each other.
+
The bright blue of the previous example was just an Xterm representation of the ANSI standard blue.
+Try to change the default settings of your client, so that blue shows as some other color, you’ll
+then realize the difference when Evennia is sending a true ANSI color (which will show up according
+to your settings) and when instead it’s sending an Xterm representation of that color (which will
+show up always as defined by Evennia).
+
You’ll have to keep in mind that the presence of an Xterm BG or FG color might affect the way your
+tags work on the text. For example:
+
say |[b Bright Blue BG |* Negative |!Y Dark Yellow |h not bright
+
+
+
Here the |h tag no longer affects the FG color. Even though it was changed via the |! tag, the
+ANSI system is out-of-tune because of the intrusion of an Xterm color (bright blue BG, then moved to
+FG with |*).
+
All unexpected ANSI behaviours are the result of mixing Xterm colors (either on purpose or either
+via bright BG colors). The |n tag will restore things in place and ANSI tags will respond properly
+again. So, at the end is just an issue of being mindful when using Xterm colors or bright BGs, and
+avoid wild mixing them with ANSI tags without normalizing (|n) things again.
+
Try this:
+
say |[b Bright Blue BG |* Negative |!R Red FG
+
+
+
And then:
+
say |[B Dark Blue BG |* Negative |!R Red BG??
+
+
+
In this second example the |! changes the BG color instead of the FG! In fact, the odd behavior is
+the one from the former example, non the latter. When you invert FG and BG with |* you actually
+inverting their references. This is why the last example (which has a normal/dark BG!) allows |!
+to change the BG color. In the first example, it’s again the presence of an Xterm color (bright blue
+BG) which changes the default behavior.
+
Try this:
+
sayNormal|*Negative|!RRedBG
+
This is the normal behavior, and as you can see it allows |! to change BG color after the
+inversion of FG and BG.
+
As long as you have an understanding of how ANSI works, it should be easy to handle color tags
+avoiding the pitfalls of Xterm-ANSI promisquity.
+
One last example:
+
sayNormal|*Negative|*stillNegative
+
Shows that |* only works once in a row and will not (and should not!) revert back if used again.
+Nor it will have any effect until the |n tag is called to “reset” ANSI back to normal. This is how
+it is meant to work.
+
ANSI operates according to a simple states-based mechanism, and it’s important to understand the
+positive effect of resetting with the |n tag, and not try to
+push it over the limit, so to speak.
Unit testing means testing components of a program in isolation from each other to make sure every
+part works on its own before using it with others. Extensive testing helps avoid new updates causing
+unexpected side effects as well as alleviates general code rot (a more comprehensive wikipedia
+article on unit testing can be found here).
+
A typical unit test set calls some function or method with a given input, looks at the result and
+makes sure that this result looks as expected. Rather than having lots of stand-alone test programs,
+Evennia makes use of a central test runner. This is a program that gathers all available tests all
+over the Evennia source code (called test suites) and runs them all in one go. Errors and
+tracebacks are reported.
+
By default Evennia only tests itself. But you can also add your own tests to your game code and have
+Evennia run those for you.
To run the full Evennia test suite, go to your game folder and issue the command
+
evennia test evennia
+
+
+
This will run all the evennia tests using the default settings. You could also run only a subset of
+all tests by specifying a subpackage of the library:
+
evennia test evennia.commands.default
+
+
+
A temporary database will be instantiated to manage the tests. If everything works out you will see
+how many tests were run and how long it took. If something went wrong you will get error messages.
+If you contribute to Evennia, this is a useful sanity check to see you haven’t introduced an
+unexpected bug.
If you have implemented your own tests for your game (see below) you can run them from your game dir
+with
+
evennia test .
+
+
+
The period (.) means to run all tests found in the current directory and all subdirectories. You
+could also specify, say, typeclasses or world if you wanted to just run tests in those subdirs.
+
Those tests will all be run using the default settings. To run the tests with your own settings file
+you must use the --settings option:
+
evennia test --settings settings.py .
+
+
+
The --settings option of Evennia takes a file name in the mygame/server/conf folder. It is
+normally used to swap settings files for testing and development. In combination with test, it
+forces Evennia to use this settings file over the default one.
Evennia’s test suite makes use of Django unit test system, which in turn relies on Python’s
+unittest module.
+
+
If you want to help out writing unittests for Evennia, take a look at Evennia’s coveralls.io page. There you see which modules have any form of
+test coverage and which does not.
+
+
To make the test runner find the tests, they must be put in a module named test*.py (so test.py,
+tests.py etc). Such a test module will be found wherever it is in the package. It can be a good
+idea to look at some of Evennia’s tests.py modules to see how they look.
+
Inside a testing file, a unittest.TestCase class is used to test a single aspect or component in
+various ways. Each test case contains one or more test methods - these define the actual tests to
+run. You can name the test methods anything you want as long as the name starts with “test_”.
+Your TestCase class can also have a method setUp(self):. This is run before each test, setting
+up and storing whatever preparations the test methods need. Conversely, a tearDown(self): method
+can optionally do cleanup after each test.
+
To test the results, you use special methods of the TestCase class. Many of those start with
+“assert”, such as assertEqual or assertTrue.
+
Example of a TestCase class:
+
importunittest
+
+ # the function we want to test
+ frommypathimportmyfunc
+
+ classTestObj(unittest.TestCase):
+ "This tests a function myfunc."
+
+ deftest_return_value(self):
+ "test method. Makes sure return value is as expected."
+ expected_return="This is me being nice."
+ actual_return=myfunc()
+ # test
+ self.assertEqual(expected_return,actual_return)
+ deftest_alternative_call(self):
+ "test method. Calls with a keyword argument."
+ expected_return="This is me being baaaad."
+ actual_return=myfunc(bad=True)
+ # test
+ self.assertEqual(expected_return,actual_return)
+
Evennia offers a custom TestCase, the evennia.utils.test_resources.EvenniaTest class. This class
+initiates a range of useful properties on themselves for testing Evennia systems. Examples are
+.account and .session representing a mock connected Account and its Session and .char1 and
+.char2 representing Characters complete with a location in the test database. These are all useful
+when testing Evennia system requiring any of the default Evennia typeclasses as inputs. See the full
+definition of the EvenniaTest class in evennia/utils/test_resources.py.
+
# in a test module
+
+fromevennia.utils.test_resourcesimportEvenniaTest
+
+classTestObject(EvenniaTest):
+ deftest_object_search(self):
+ # char1 and char2 are both created in room1
+ self.assertEqual(self.char1.search(self.char2.key),self.char2)
+ self.assertEqual(self.char1.search(self.char1.location.key),self.char1.location)
+ # ...
+
In-game Commands are a special case. Tests for the default commands are put in
+evennia/commands/default/tests.py. This uses a custom CommandTest class that inherits from
+evennia.utils.test_resources.EvenniaTest described above. CommandTest supplies extra convenience
+functions for executing commands and check that their return values (calls of msg() returns
+expected values. It uses Characters and Sessions generated on the EvenniaTest class to call each
+class).
+
Each command tested should have its own TestCase class. Inherit this class from the CommandTest
+class in the same module to get access to the command-specific utilities mentioned.
+
fromevennia.commands.default.testsimportCommandTest
+ fromevennia.commands.defaultimportgeneral
+ classTestSet(CommandTest):
+ "tests the look command by simple call, using Char2 as a target"
+ deftest_mycmd_char(self):
+ self.call(general.CmdLook(),"Char2","Char2(#7)")
+ "tests the look command by simple call, with target as room"
+ deftest_mycmd_room(self):
+ self.call(general.CmdLook(),"Room",
+ "Room(#1)\nroom_desc\nExits: out(#3)\n"
+ "You see: Obj(#4), Obj2(#5), Char2(#7)")
+
A special case is if you were to create a contribution to go to the evennia/contrib folder that
+uses its own database models. The problem with this is that Evennia (and Django) will
+only recognize models in settings.INSTALLED_APPS. If a user wants to use your contrib, they will
+be required to add your models to their settings file. But since contribs are optional you cannot
+add the model to Evennia’s central settings_default.py file - this would always create your
+optional models regardless of if the user wants them. But at the same time a contribution is a part
+of the Evennia distribution and its unit tests should be run with all other Evennia tests using
+evenniatestevennia.
+
The way to do this is to only temporarily add your models to the INSTALLED_APPS directory when the
+test runs. here is an example of how to do it.
+
+
Note that this solution, derived from this stackexchange answer is currently untested! Please report your findings.
Having an extensive tests suite is very important for avoiding code degradation as Evennia is
+developed. Only a small fraction of the Evennia codebase is covered by test suites at this point.
+Writing new tests is not hard, it’s more a matter of finding the time to do so. So adding new tests
+is really an area where everyone can contribute, also with only limited Python skills.
If you have custom models with a large number of migrations, creating the test database can take a
+very long time. If you don’t require migrations to run for your tests, you can disable them with the
+django-test-without-migrations package. To install it, simply:
+
$ pip install django-test-without-migrations
+
+
+
Then add it to your INSTALLED_APPS in your server.conf.settings.py:
Unit testing can be of paramount importance to game developers. When starting with a new game, it is
+recommended to look into unit testing as soon as possible; an already huge game is much harder to
+write tests for. The benefits of testing a game aren’t different from the ones regarding library
+testing. For example it is easy to introduce bugs that affect previously working code. Testing is
+there to ensure your project behaves the way it should and continue to do so.
+
If you have never used unit testing (with Python or another language), you might want to check the
+official Python documentation about unit testing,
+particularly the first section dedicated to a basic example.
Evennia’s test runner can be used to launch tests in your game directory (let’s call it ‘mygame’).
+Evennia’s test runner does a few useful things beyond the normal Python unittest module:
+
+
It creates and sets up an empty database, with some useful objects (accounts, characters and
+rooms, among others).
+
It provides simple ways to test commands, which can be somewhat tricky at times, if not tested
+properly.
+
+
Therefore, you should use the command-line to execute the test runner, while specifying your own
+game directories (not the one containing evennia). Go to your game directory (referred as ‘mygame’
+in this section) and execute the test runner:
+
evennia test --settings settings.py commands
+
+
+
This command will execute Evennia’s test runner using your own settings file. It will set up a dummy
+database of your choice and look into the ‘commands’ package defined in your game directory
+(mygame/commands in this example) to find tests. The test module’s name should begin with ‘test’
+and contain one or more TestCase. A full example can be found below.
In your game directory, go to commands and create a new file tests.py inside (it could be named
+anything starting with test). We will start by making a test that has nothing to do with Commands,
+just to show how unit testing works:
+
# mygame/commands/tests.py
+
+ importunittest
+
+ classTestString(unittest.TestCase):
+
+ """Unittest for strings (just a basic example)."""
+
+ deftest_upper(self):
+ """Test the upper() str method."""
+ self.assertEqual('foo'.upper(),'FOO')
+
+
+
This example, inspired from the Python documentation, is used to test the ‘upper()’ method of the
+‘str’ class. Not very useful, but it should give you a basic idea of how tests are used.
+
Let’s execute that test to see if it works.
+
> evennia test --settings settings.py commands
+
+TESTING: Using specified settings file 'server.conf.settings'.
+
+(Obs: Evennia's full test suite may not pass if the settings are very
+different from the default. Use 'test .' as arguments to run only tests
+on the game dir.)
+
+Creating test database for alias 'default'...
+.
+----------------------------------------------------------------------
+Ran 1 test in 0.001s
+
+OK
+Destroying test database for alias 'default'...
+
+
+
We specified the commands package to the evennia test command since that’s where we put our test
+file. In this case we could just as well just said . to search all of mygame for testing files.
+If we have a lot of tests it may be useful to test only a single set at a time though. We get an
+information text telling us we are using our custom settings file (instead of Evennia’s default
+file) and then the test runs. The test passes! Change the “FOO” string to something else in the test
+to see how it looks when it fails.
This section will test the proper execution of the ‘abilities’ command, as described in the
+First Steps Coding page. Follow this tutorial to create the ‘abilities’ command, we
+will need it to test it.
+
Testing commands in Evennia is a bit more complex than the simple testing example we have seen.
+Luckily, Evennia supplies a special test class to do just that … we just need to inherit from it
+and use it properly. This class is called ‘CommandTest’ and is defined in the
+‘evennia.commands.default.tests’ package. To create a test for our ‘abilities’ command, we just
+need to create a class that inherits from ‘CommandTest’ and add methods.
+
We could create a new test file for this but for now we just append to the tests.py file we
+already have in commands from before.
Line 1-4: we do some importing. ‘CommandTest’ is going to be our base class for our test, so we
+need it. We also import our command (‘CmdAbilities’ in this case). Finally we import the
+‘Character’ typeclass. We need it, since ‘CommandTest’ doesn’t use ‘Character’, but
+‘DefaultCharacter’, which means the character calling the command won’t have the abilities we have
+written in the ‘Character’ typeclass.
+
Line 6-8: that’s the body of our test. Here, a single command is tested in an entire class.
+Default commands are usually grouped by category in a single class. There is no rule, as long as
+you know where you put your tests. Note that we set the ‘character_typeclass’ class attribute to
+Character. As explained above, if you didn’t do that, the system would create a ‘DefaultCharacter’
+object, not a ‘Character’. You can try to remove line 4 and 8 to see what happens when running the
+test.
+
Line 10-11: our unique testing method. Note its name: it should begin by ‘test_’. Apart from
+that, the method is quite simple: it’s an instance method (so it takes the ‘self’ argument) but no
+other arguments are needed. Line 11 uses the ‘call’ method, which is defined in ‘CommandTest’.
+It’s a useful method that compares a command against an expected result. It would be like comparing
+two strings with ‘assertEqual’, but the ‘call’ method does more things, including testing the
+command in a realistic way (calling its hooks in the right order, so you don’t have to worry about
+that).
+
+
Line 11 can be understood as: test the ‘abilities’ command (first parameter), with no argument
+(second parameter), and check that the character using it receives his/her abilities (third
+parameter).
+
Let’s run our new test:
+
> evennia test --settings settings.py commands
+[...]
+Creating test database for alias 'default'...
+..
+----------------------------------------------------------------------
+Ran 2 tests in 0.156s
+
+OK
+Destroying test database for alias 'default'...
+
+
+
Two tests were executed, since we have kept ‘TestString’ from last time. In case of failure, you
+will get much more information to help you fix the bug.
Having read the unit test tutorial on Testing commands we can see
+the code expects static unchanging numbers. While very good for learning it is unlikely a project
+will have nothing but static output to test. Here we are going to learn how to test against dynamic
+output.
+
This tutorial assumes you have a basic understanding of what regular expressions are. If you do not
+I recommend reading the Introduction and SimplePattern sections at
+Python regular expressions tutorial. If you do plan on making a complete Evennia
+project learning regular expressions will save a great deal of time.
Noticed that we removed the test string from self.call. That method always returns the string it
+found from the commands output. If we remove the string to test against, all self.call will do is
+return the screen output from the command object passed to it.
+
We are instead using the next line to test our command’s output.
+assertRegex is a
+method of unittest.TestCase this is inherited to TestDynamicAbilities from CommandTest who
+inherited it from EvenniaTest.
+
What we are doing is testing the result of the CmdAbilities method or command against a regular
+expression pattern. In this case, "STR:\d+,AGI:\d+,MAG:\d+". \d in regular expressions or
+regex are digits (numbers), the + is to state we want 1 or more of that pattern. Together \d+ is
+telling assertRegex
+that in that position of the string we expect 1 or more digits (numbers) to appear in the
+string.
Fortunately, it’s extremely easy to keep your Evennia server up-to-date. If you haven’t already, see
+the Getting Started guide and get everything running.
Very commonly we make changes to the Evennia code to improve things. There are many ways to get told
+when to update: You can subscribe to the RSS feed or manually check up on the feeds from
+http://www.evennia.com. You can also simply fetch the latest regularly.
+
When you’re wanting to apply updates, simply cd to your cloned evennia root directory and type:
+
git pull
+
+
+
assuming you’ve got the command line client. If you’re using a graphical client, you will probably
+want to navigate to the evennia directory and either right click and find your client’s pull
+function, or use one of the menus (if applicable).
+
You can review the latest changes with
+
git log
+
+
+
or the equivalent in the graphical client. You can also see the latest changes online
+here.
+
You will always need to do evenniareload (or reload from -in-game) from your game-dir to have
+the new code affect your game. If you want to be really sure you should run a full evenniareboot
+so that both Server and Portal can restart (this will disconnect everyone though, so if you know the
+Portal has had no updates you don’t have to do that).
On occasion we update the versions of third-party libraries Evennia depend on (or we may add a new
+dependency). This will be announced on the mailing list/forum. If you run into errors when starting
+Evennia, always make sure you have the latest versions of everything. In some cases, like for
+Django, starting the server may also give warning saying that you are using a working, but too-old
+version that should not be used in production.
+
Upgrading evennia will automatically fetch all the latest packages that it now need. First cd to
+your cloned evennia folder. Make sure your virtualenv is active and use
+
pip install --upgrade -e .
+
+
+
Remember the period (.) at the end - that applies the upgrade to the current location (your
+evennia dir).
+
+
The -e means that we are linking the evennia sources rather than copying them into the
+environment. This means we can most of the time just update the sources (with gitpull) and see
+those changes directly applied to our installed evennia package. Without installing/upgrading the
+package with -e, we would have to remember to upgrade the package every time we downloaded any new
+source-code changes.
+
+
Follow the upgrade output to make sure it finishes without errors. To check what packages are
+currently available in your python environment after the upgrade, use
+
pip list
+
+
+
This will show you the version of all installed packages. The evennia package will also show the
+location of its source code.
Whenever we change the database layout of Evennia upstream (such as when we add new features) you
+will need to migrate your existing database. When this happens it will be clearly noted in the
+gitlog (it will say something to the effect of “Run migrations”). Database changes will also be
+announced on the Evennia mailing list.
+
When the database schema changes, you just go to your game folder and run
+
evennia migrate
+
+
+
+
Hint: If the evennia command is not found, you most likely need to activate your
+virtualenv.
Should you ever want to start over completely from scratch, there is no need to re-download Evennia
+or anything like that. You just need to clear your database. Once you are done, you just rebuild it
+from scratch as described in the second step of the Getting Started guide.
+
First stop a running server with
+
evennia stop
+
+
+
If you run the default SQlite3 database (to change this you need to edit your settings.py file),
+the database is actually just a normal file in mygame/server/ called evennia.db3. Simply delete
+that file - that’s it. Now run evenniamigrate to recreate a new, fresh one.
+
If you run some other database system you can instead flush the database:
+
evennia flush
+
+
+
This will empty the database. However, it will not reset the internal counters of the database, so
+you will start with higher dbref values. If this is okay, this is all you need.
+
Django also offers an easy way to start the database’s own management should we want more direct
+control:
+
evennia dbshell
+
+
+
In e.g. MySQL you can then do something like this (assuming your MySQL database is named “Evennia”:
+
mysql> DROP DATABASE Evennia;
+mysql> exit
+
+
+
+
NOTE: Under Windows OS, in order to access SQLite dbshell you need to download the SQLite
+command-line shell program. It’s a single executable file
+(sqlite3.exe) that you should place in the root of either your MUD folder or Evennia’s (it’s the
+same, in both cases Django will find it).
If and when an Evennia update modifies the database schema (that is, the under-the-hood details as
+to how data is stored in the database), you must update your existing database correspondingly to
+match the change. If you don’t, the updated Evennia will complain that it cannot read the database
+properly. Whereas schema changes should become more and more rare as Evennia matures, it may still
+happen from time to time.
+
One way one could handle this is to apply the changes manually to your database using the database’s
+command line. This often means adding/removing new tables or fields as well as possibly convert
+existing data to match what the new Evennia version expects. It should be quite obvious that this
+quickly becomes cumbersome and error-prone. If your database doesn’t contain anything critical yet
+it’s probably easiest to simply reset it and start over rather than to bother converting.
+
Enter migrations. Migrations keeps track of changes in the database schema and applies them
+automatically for you. Basically, whenever the schema changes we distribute small files called
+“migrations” with the source. Those tell the system exactly how to implement the change so you don’t
+have to do so manually. When a migration has been added we will tell you so on Evennia’s mailing
+lists and in commit messages -
+you then just run evenniamigrate to be up-to-date again.
Evennia allows for any command syntax. If you like the way DikuMUDs, LPMuds or MOOs handle things,
+you could emulate that with Evennia. If you are ambitious you could even design a whole new style,
+perfectly fitting your own dreams of the ideal game.
+
We do offer a default however. The default Evennia setup tends to resemble
+MUX2, and its cousins PennMUSH,
+TinyMUSH, and RhostMUSH. While the
+reason for this similarity is partly historical, these codebases offer very mature feature sets for
+administration and building.
+
Evennia is not a MUX system though. It works very differently in many ways. For example, Evennia
+deliberately lacks an online softcode language (a policy explained on our softcode policy
+page). Evennia also does not shy from using its own syntax when deemed appropriate: the
+MUX syntax has grown organically over a long time and is, frankly, rather arcane in places. All in
+all the default command syntax should at most be referred to as “MUX-like” or “MUX-inspired”.
Angled brackets <> surround a description of what to write rather than the exact syntax.
+
*Explicit choices are separated by |. To avoid this being parsed as a color code, use || (this
+will come out as a single |) or put spaces around the character (“|”) if there’s plenty of
+room.
+
The Switches and Examples blocks are optional based on the Command.
+
+
Here is the nick command as an example:
+
"""
+ Define a personal alias/nick
+
+ Usage:
+ nick[/switches] <nickname> = [<string>]
+ alias ''
+
+ Switches:
+ object - alias an object
+ account - alias an account
+ clearall - clear all your aliases
+ list - show all defined aliases (also "nicks" works)
+
+ Examples:
+ nick hi = say Hello, I'm Sarah!
+ nick/object tom = the tall man
+
+ A 'nick' is a personal shortcut you create for your own use [...]
+
+ """
+
+
+
For commands that require arguments, the policy is for it to return a Usage: string if the
+command is entered without any arguments. So for such commands, the Command body should contain
+something to the effect of
Evennia uses Travis CI to check that it’s building successfully after every
+commit to its Github repository (you can for example see the build:passing badge at the top of
+Evennia’s Readme file). If your game is open source on Github
+you may use Travis for free. See the Travis docs
+for how to get started.
+
After logging in you need to point Travis to your repository on github. One further thing you need
+to set up yourself is a Travis config file named .travis.yml (note the initial period .). This
+should be created in the root of your game directory.
Here we tell Travis how to download and install Evennia into a folder a level up from your game dir.
+It will then install the server (so the evennia command is available) and run the tests only for
+your game dir (based on your settings.py file in server/conf/).
+
Running this will not actually do anything though, because there are no unit tests in your game dir
+yet. We have a page on how we set those up for Evennia, you should be able to refer
+to that for making tests fitting your game.
Version control software allows you to track the changes you make to your code, as well as being
+able to easily backtrack these changes, share your development efforts and more. Even if you are not
+contributing to Evennia itself, and only wish to develop your own MU* using Evennia, having a
+version control system in place is a good idea (and standard coding practice). For an introduction
+to the concept, start with the Wikipedia article
+here. Evennia uses the version control system
+Git and this is what will be covered henceforth. Note that this page also
+deals with commands for Linux operating systems, and the steps below may vary for other systems,
+however where possible links will be provided for alternative instructions.
If you have gotten Evennia installed, you will have Git already and can skip to Step 2 below.
+Otherwise you will need to install Git on your platform. You can find expanded instructions for
+installation here.
To avoid a common issue later, you will need to set a couple of settings; first you will need to
+tell Git your username, followed by your e-mail address, so that when you commit code later you will
+be properly credited.
+
+
Note that your commit information will be visible to everyone if you ever contribute to Evennia or
+use an online service like github to host your code. So if you are not comfortable with using your
+real, full name online, put a nickname here.
+
+
+
Set the default name for git to use when you commit:
+
git config --global user.name "Your Name Here"
+
+
+
+
Set the default email for git to use when you commit:
When working on your code or fix bugs in your local branches you may end up creating new files. If
+you do you must tell Git to track them by using the add command:
+
gitadd<filename>
+
+
+
You can check the current status of version control with gitstatus. This will show if you have
+any modified, added or otherwise changed files. Some files, like database files, logs and temporary
+PID files are usually not tracked in version control. These should either not show up or have a
+question mark in front of them.
You will notice that some files are not covered by your git version control, notably your settings
+file (mygame/server/conf/settings.py) and your sqlite3 database file mygame/server/evennia.db3.
+This is controlled by the hidden file mygame/.gitignore. Evennia creates this file as part of the
+creation of your game directory. Everything matched in this file will be ignored by GIT. If you want
+to, for example, include your settings file for collaborators to access, remove that entry in
+.gitignore.
+
+
Note: You should never put your sqlite3 database file into git by removing its entry in
+.gitignore. GIT is for backing up your code, not your database. That way lies madness and a good
+chance you’ll confuse yourself so that after a few commits and reverts don’t know what is in your
+database or not. If you want to backup your database, do so by simply copying the file on your hard
+drive to a backup-name.
Committing means storing the current snapshot of your code within git. This creates a “save point”
+or “history” of your development process. You can later jump back and forth in your history, for
+example to figure out just when a bug was introduced or see what results the code used to produce
+compared to now.
+
+
It’s usually a good idea to commit your changes often. Committing is fast and local only - you will
+never commit anything online at this point. To commit your changes, use
+
gitcommit--all
+
+
+
This will save all changes you made since last commit. The command will open a text editor where you
+can add a message detailing the changes you’ve made. Make it brief but informative. You can see the
+history of commits with gitlog. If you don’t want to use the editor you can set the message
+directly by using the -m flag:
+
gitcommit--all-m"This fixes a bug in the combat code."
+
If you have non-committed changes that you realize you want to throw away, you can do the following:
+
gitcheckout<filetorevert>
+
+
+
This will revert the file to the state it was in at your last commit, throwing away the changes
+you did to it since. It’s a good way to make wild experiments without having to remember just what
+you changed. If you do gitcheckout. you will throw away all changes since the last commit.
So far your code is only located on your private machine. A good idea is to back it up online. The
+easiest way to do this is to push it to your own remote repository on GitHub.
+
+
Make sure you have your game directory setup under git version control as described above. Make
+sure to commit any changes.
+
Create a new, empty repository on Github. Github explains how
+here (do not “Initialize the repository with a
+README” or else you’ll create unrelated histories).
+
From your local game dir, do gitremoteaddorigin<githubURL> where <githubURL> is the URL
+to your online repo. This tells your game dir that it should be pushing to the remote online dir.
+
gitremote-v to verify the online dir.
+
gitpushoriginmaster now pushes your game dir online so you can see it on github.com.
+
+
You can commit your work locally (gitcommit--all-m"Makeachangethat...") as many times as
+you want. When you want to push those changes to your online repo, you do gitpush. You can also
+gitclone<url_to_online_repo> from your online repo to somewhere else (like your production
+server) and henceforth do gitpull to update that to the latest thing you pushed.
+
Note that GitHub’s repos are, by default publicly visible by all. Creating a publicly visible online
+clone might not be what you want for all parts of your development process - you may prefer a more
+private venue when sharing your revolutionary work with your team. If that’s the case you can change
+your repository to “Private” in the github settings. Then your code will only be visible to those
+you specifically grant access.
Before proceeding with the following step, make sure you have registered and created an account on
+GitHub.com. This is necessary in order to create a fork of Evennia’s master
+repository, and to push your commits to your fork either for yourself or for contributing to
+Evennia.
+
+
A fork is a clone of the master repository that you can make your own commits and changes to. At
+the top of this page, click the “Fork” button, as it appears
+below.
The fork only exists online as of yet. In a terminal, change your directory to the folder you wish
+to develop in. From this directory run the following command:
A remote is a repository stored on another computer, in this case on GitHub’s server. When a
+repository is cloned, it has a default remote called origin. This points to your fork on GitHub,
+not the original repository it was forked from. To easily keep track of the original repository
+(that is, Evennia’s official repository), you need to add another remote. The standard name for this
+remote is “upstream”.
+
Below we change the active directory to the newly cloned “evennia” directory and then assign the
+original Evennia repository to a remote called “upstream”:
If you also want to access Evennia’s develop branch (the bleeding edge development branch) do the
+following:
+
gitfetchupstreamdevelop
+gitcheckoutdevelop
+
+
+
You should now have the upstream branch available locally. You can use this instead of master
+below if you are contributing new features rather than bug fixes.
A branch is a separate instance of your code. Changes you do to code in a branch does not affect
+that in other branches (so if you for example add/commit a file to one branch and then switches to
+another branch, that file will be gone until you switch back to the first branch again). One can
+switch between branches at will and create as many branches as one needs for a given project. The
+content of branches can also be merged together or deleted without affecting other branches. This is
+not only a common way to organize development but also to test features without messing with
+existing code.
+
+
The default branch of git is called the “master” branch. As a rule of thumb, you should never
+make modifications directly to your local copy of the master branch. Rather keep the master clean
+and only update it by pulling our latest changes to it. Any work you do should instead happen in a
+local, other branches.
This command will checkout and automatically create the new branch myfixes on your machine. If you
+stared out in the master branch, myfixes will be a perfect copy of the master branch. You can see
+which branch you are on with gitbranch and change between different branches with gitcheckout<branchname>.
+
Branches are fast and cheap to create and manage. It is common practice to create a new branch for
+every bug you want to work on or feature you want to create, then create a pull request for that
+branch to be merged upstream (see below). Not only will this organize your work, it will also make
+sure that your master branch version of Evennia is always exactly in sync with the upstream
+version’s master branch.
When Evennia’s official repository updates, first make sure to commit all your changes to your
+branch and then checkout the “clean” master branch:
+
gitcommit--all
+gitcheckoutmaster
+
+
+
Pull the latest changes from upstream:
+
gitpullupstreammaster
+
+
+
This should sync your local master branch with upstream Evennia’s master branch. Now we go back to
+our own work-branch (let’s say it’s still called “myfixes”) and merge the updated master into our
+branch.
+
gitcheckoutmyfixes
+gitmergemaster
+
+
+
If everything went well, your myfixes branch will now have the latest version of Evennia merged
+with whatever changes you have done. Use gitlog to see what has changed. You may need to restart
+the server or run manage.pymigrate if the database schema changed (this will be seen in the
+commit log and on the mailing list). See the Git manuals for
+learning more about useful day-to-day commands, and special situations such as dealing with merge
+collisions.
Up to this point your myfixes branch only exists on your local computer. No one else can see it.
+If you want a copy of this branch to also appear in your online fork on GitHub, make sure to have
+checked out your “myfixes” branch and then run the following:
+
gitpush-uoriginmyfixes
+
+
+
This will create a new remote branch named “myfixes” in your online repository (which is refered
+to as “origin” by default); the -u flag makes sure to set this to the default push location.
+Henceforth you can just use gitpush from your myfixes branch to push your changes online. This is
+a great way to keep your source backed-up and accessible. Remember though that by default your
+repository will be public so everyone will be able to browse and download your code (same way as you
+can with Evennia itself). If you want secrecy you can change your repository to “Private” in the
+Github settings. Note though that if you do, you might have trouble contributing to Evennia (since
+we can’t see the code you want to share).
+
Note: If you hadn’t setup a public key on GitHub or aren’t asked for a username/password, you might
+get an error 403:ForbiddenAccess at this stage. In that case, some users have reported that the
+workaround is to create a file .netrc under your home directory and add your credentials there:
Contributing can mean both bug-fixes or adding new features to Evennia. Please note that if your
+change is not already listed and accepted in the Issue
+Tracker, it is recommended that you first hit the
+developer mailing list or IRC chat to see beforehand if your feature is deemed suitable to include
+as a core feature in the engine. When it comes to bug-fixes, other developers may also have good
+input on how to go about resolving the issue.
+
To contribute you need to have forked Evennia first. As described
+above you should do your modification in a separate local branch (not in the master branch). This
+branch is what you then present to us (as a Pull request, PR, see below). We can then merge your
+change into the upstream master and you then do gitpull to update master usual. Now that the
+master is updated with your fixes, you can safely delete your local work branch. Below we describe
+this work flow.
+
First update the Evennia master branch to the latest Evennia version:
+
gitcheckoutmaster
+gitpullupstreammaster
+
+
+
Next, create a new branch to hold your contribution. Let’s call it the “fixing_strange_bug” branch:
+
gitcheckout-bfixing_strange_bug
+
+
+
It is wise to make separate branches for every fix or series of fixes you want to contribute. You
+are now in your new fixing_strange_bug branch. You can list all branches with gitbranch and
+jump between branches with gitcheckout<branchname>. Code and test things in here, committing as
+you go:
+
gitcommit--all-m"Fix strange bug in look command. Resolves #123."
+
+
+
You can make multiple commits if you want, depending on your work flow and progress. Make sure to
+always make clear and descriptive commit messages so it’s easy to see what you intended. To refer
+to, say, issue number 123, write #123, it will turn to a link on GitHub. If you include the text
+“Resolves #123”, that issue will be auto-closed on GitHub if your commit gets merged into main
+Evennia.
+
+
If you refer to in-game commands that start with @(such as @examine), please put them in
+backticks `, for example `@examine`. The reason for this is that GitHub uses @username to refer
+to GitHub users, so if you forget the ticks, any user happening to be named examine will get a
+notification …
+
+
If you implement multiple separate features/bug-fixes, split them into different branches if they
+are very different and should be handled as separate PRs. You can do any number of commits to your
+branch as you work. Once you are at a stage where you want to show the world what you did you might
+want to consider making it clean for merging into Evennia’s master branch by using git
+rebase (this is not always necessary,
+and if it sounds too hard, say so and we’ll handle it on our end).
+
Once you are ready, push your work to your online Evennia fork on github, in a new remote branch:
+
gitpush-uoriginfixing_strange_bug
+
+
+
The -u flag is only needed the first time - this tells GIT to create a remote branch. If you
+already created the remote branch earlier, just stand in your fixing_strange_bug branch and do
+gitpush.
+
Now you should tell the Evennia developers that they should consider merging your brilliant changes
+into Evennia proper. Create a pull request and follow
+the instructions. Make sure to specifically select your fixing_strange_bug branch to be the source
+of the merge. Evennia developers will then be able to examine your request and merge it if it’s
+deemed suitable.
+
Once your changes have been merged into Evennia your local fixing_strange_bug can be deleted
+(since your changes are now available in the “clean” Evennia repository). Do
+
gitbranch-Dfixing_strange_bug
+
+
+
to delete your work branch. Update your master branch (checkoutmaster and then gitpull) and
+you should get your fix back, now as a part of official Evennia!
Some of the GIT commands can feel a little long and clunky if you need to do them often. Luckily you
+can create aliases for those. Here are some useful commands to run:
+
# git st
+# - view brief status info
+gitconfig--globalalias.st'status -s'
+
+
+
Above, you only need to ever enter the gitconfig... command once - you have then added the new
+alias. Afterwards, just do gitst to get status info. All the examples below follow the same
+template.
+
# git cl
+# - clone a repository
+gitconfig--globalalias.clclone
+
+
+
# git cma "commit message"
+# - commit all changes without opening editor for message
+gitconfig--globalalias.cma'commit -a -m'
+
+
+
# git ca
+# - amend text to your latest commit message
+gitconfig--globalalias.ca'commit --amend'
+
+
+
# git fl
+# - file log; shows diffs of files in latest commits
+gitconfig--globalalias.fl'log -u'
+
+
+
# git co [branchname]
+# - checkout
+gitconfig--globalalias.cocheckout
+
# git ls
+# - view log tree
+gitconfig--globalalias.ls'log --pretty=format:"%C(green)%h\ %C(yellow)[%ad]%Cred%d\
+%Creset%s%Cblue\ [%cn]" --decorate --date=relative --graph'
+
+
+
# git diff
+# - show current uncommitted changes
+gitconfig--globalalias.diff'diff --word-diff'
+
+
+
# git grep <query>
+# - search (grep) codebase for a search criterion
+gitconfig--globalalias.grep'grep -Ii'
+
+
+
To get a further feel for GIT there is also a good YouTube talk about
+it - it’s a bit long but it will help you
+understand the underlying ideas behind GIT
+(which in turn makes it a lot more intuitive to use).
This tutorial will have us create a simple weather system for our MUD. The way we want to use this
+is to have all outdoor rooms echo weather-related messages to the room at regular and semi-random
+intervals. Things like “Clouds gather above”, “It starts to rain” and so on.
+
One could imagine every outdoor room in the game having a script running on themselves that fires
+regularly. For this particular example it is however more efficient to do it another way, namely by
+using a “ticker-subscription” model. The principle is simple: Instead of having each Object
+individually track the time, they instead subscribe to be called by a global ticker who handles time
+keeping. Not only does this centralize and organize much of the code in one place, it also has less
+computing overhead.
+
Evennia offers the TickerHandler specifically for using the subscription model. We
+will use it for our weather system.
+
We will assume you know how to make your own Typeclasses. If not see one of the beginning tutorials.
+We will create a new WeatherRoom typeclass that is aware of the day-night cycle.
+
+ importrandom
+ fromevenniaimportDefaultRoom,TICKER_HANDLER
+
+ ECHOES=["The sky is clear.",
+ "Clouds gather overhead.",
+ "It's starting to drizzle.",
+ "A breeze of wind is felt.",
+ "The wind is picking up"]# etc
+
+ classWeatherRoom(DefaultRoom):
+ "This room is ticked at regular intervals"
+
+ defat_object_creation(self):
+ "called only when the object is first created"
+ TICKER_HANDLER.add(60*60,self.at_weather_update)
+
+ defat_weather_update(self,*args,**kwargs):
+ "ticked at regular intervals"
+ echo=random.choice(ECHOES)
+ self.msg_contents(echo)
+
+
+
In the at_object_creation method, we simply added ourselves to the TickerHandler and tell it to
+call at_weather_update every hour (60*60 seconds). During testing you might want to play with a
+shorter time duration.
+
For this to work we also create a custom hook at_weather_update(*args,**kwargs), which is the
+call sign required by TickerHandler hooks.
+
Henceforth the room will inform everyone inside it when the weather changes. This particular example
+is of course very simplistic - the weather echoes are just randomly chosen and don’t care what
+weather came before it. Expanding it to be more realistic is a useful exercise.
This tutorial will create a simple web-based interface for generating a new in-game Character.
+Accounts will need to have first logged into the website (with their AccountDB account). Once
+finishing character generation the Character will be created immediately and the Accounts can then
+log into the game and play immediately (the Character will not require staff approval or anything
+like that). This guide does not go over how to create an AccountDB on the website with the right
+permissions to transfer to their web-created characters.
+
It is probably most useful to set MULTISESSION_MODE=2 or 3 (which gives you a character-
+selection screen when you log into the game later). Other modes can be used with some adaptation to
+auto-puppet the new Character.
+
You should have some familiarity with how Django sets up its Model Template View framework. You need
+to understand what is happening in the basic Web Character View tutorial. If you don’t understand the listed tutorial or have a grasp of Django basics, please look
+at the Django tutorial to get a taste of what Django
+does, before throwing Evennia into the mix (Evennia shares its API and attributes with the website
+interface). This guide will outline the format of the models, views, urls, and html templates
+needed.
Here are some screenshots of the simple app we will be making.
+
Index page, with no character application yet done:
+
+
+
+
Having clicked the “create” link you get to create your character (here we will only have name and
+background, you can add whatever is needed to fit your game):
+
+
+
+
Back to the index page. Having entered our character application (we called our character “TestApp”)
+you see it listed:
+
+
+
+
We can also view an already written character application by clicking on it - this brings us to the
+detail page:
Assuming your game is named “mygame”, navigate to your mygame/ directory, and type:
+
evennia startapp chargen
+
+
+
This will initialize a new Django app we choose to call “chargen”. It is directory containing some
+basic starting things Django needs. You will need to move this directory: for the time being, it is
+in your mygame directory. Better to move it in your mygame/web directory, so you have
+mygame/web/chargen in the end.
+
Next, navigate to mygame/server/conf/settings.py and add or edit the following line to make
+Evennia (and Django) aware of our new app:
+
INSTALLED_APPS += ('web.chargen',)
+
+
+
After this, we will get into defining our models (the description of the database storage),
+views (the server-side website content generators), urls (how the web browser finds the pages)
+and templates (how the web page should be structured).
Models are created in mygame/web/chargen/models.py.
+
A Django database model is a Python class that describes the database storage of the
+data you want to manage. Any data you choose to store is stored in the same database as the game and
+you have access to all the game’s objects here.
+
We need to define what a character application actually is. This will differ from game to game so
+for this tutorial we will define a simple character sheet with the following database fields:
+
+
app_id (AutoField): Primary key for this character application sheet.
+
char_name (CharField): The new character’s name.
+
date_applied (DateTimeField): Date that this application was received.
+
background (TextField): Character story background.
+
account_id (IntegerField): Which account ID does this application belong to? This is an
+AccountID from the AccountDB object.
+
submitted (BooleanField): True/False depending on if the application has been submitted yet.
+
+
+
Note: In a full-fledged game, you’d likely want them to be able to select races, skills,
+attributes and so on.
+
+
Our models.py file should look something like this:
You should consider how you are going to link your application to your account. For this tutorial,
+we are using the account_id attribute on our character application model in order to keep track of
+which characters are owned by which accounts. Since the account id is a primary key in Evennia, it
+is a good candidate, as you will never have two of the same IDs in Evennia. You can feel free to use
+anything else, but for the purposes of this guide, we are going to use account ID to join the
+character applications with the proper account.
you should have filled out mygame/web/chargen/models.py with the model class shown above
+(eventually adding fields matching what you need for your game).
Views are server-side constructs that make dynamic data available to a web page. We are going to
+add them to mygame/web/chargen.views.py. Each view in our example represents the backbone of a
+specific web page. We will use three views and three pages here:
+
+
The index (managing index.html). This is what you see when you navigate to
+http://yoursite.com/chargen.
+
The detail display sheet (manages detail.html). A page that passively displays the stats of a
+given Character.
+
Character creation sheet (manages create.html). This is the main form with fields to fill in.
We’ll want characters to be able to see their created characters so let’s
+
# file mygame/web/chargen.views.py
+
+from.modelsimportCharApp
+
+defindex(request):
+ current_user=request.user# current user logged in
+ p_id=current_user.id# the account id
+ # submitted Characters by this account
+ sub_apps=CharApp.objects.filter(account_id=p_id,submitted=True)
+ context={'sub_apps':sub_apps}
+ # make the variables in 'context' available to the web page template
+ returnrender(request,'chargen/index.html',context)
+
Our detail page will have pertinent character application information our users can see. Since this
+is a basic demonstration, our detail page will only show two fields:
+
+
Character name
+
Character background
+
+
We will use the account ID again just to double-check that whoever tries to check our character page
+is actually the account who owns the application.
Predictably, our create function will be the most complicated of the views, as it needs to accept
+information from the user, validate the information, and send the information to the server. Once
+the form content is validated will actually create a playable Character.
+
The form itself we will define first. In our simple example we are just looking for the Character’s
+name and background. This form we create in mygame/web/chargen/forms.py:
# file mygame/web/chargen/views.py
+
+fromweb.chargen.modelsimportCharApp
+fromweb.chargen.formsimportAppForm
+fromdjango.httpimportHttpResponseRedirect
+fromdatetimeimportdatetime
+fromevennia.objects.modelsimportObjectDB
+fromdjango.confimportsettings
+fromevennia.utilsimportcreate
+
+defcreating(request):
+ user=request.user
+ ifrequest.method=='POST':
+ form=AppForm(request.POST)
+ ifform.is_valid():
+ name=form.cleaned_data['name']
+ background=form.cleaned_data['background']
+ applied_date=datetime.now()
+ submitted=True
+ if'save'inrequest.POST:
+ submitted=False
+ app=CharApp(char_name=name,background=background,
+ date_applied=applied_date,account_id=user.id,
+ submitted=submitted)
+ app.save()
+ ifsubmitted:
+ # Create the actual character object
+ typeclass=settings.BASE_CHARACTER_TYPECLASS
+ home=ObjectDB.objects.get_id(settings.GUEST_HOME)
+ # turn the permissionhandler to a string
+ perms=str(user.permissions)
+ # create the character
+ char=create.create_object(typeclass=typeclass,key=name,
+ home=home,permissions=perms)
+ user.db._playable_characters.append(char)
+ # add the right locks for the character so the account can
+ # puppet it
+ char.locks.add("puppet:id(%i) or pid(%i) or perm(Developers) "
+ "or pperm(Developers)"%(char.id,user.id))
+ char.db.background=background# set the character background
+ returnHttpResponseRedirect('/chargen')
+ else:
+ form=AppForm()
+ returnrender(request,'chargen/create.html',{'form':form})
+
+
+
+
Note also that we basically create the character using the Evennia API, and we grab the proper
+permissions from the AccountDB object and copy them to the character object. We take the user
+permissions attribute and turn that list of strings into a string object in order for the
+create_object function to properly process the permissions.
+
+
Most importantly, the following attributes must be set on the created character object:
The Character’s home room location (#2 by default)
+
+
Other attributes are strictly speaking optional, such as the background attribute on our
+character. It may be a good idea to decompose this function and create a separate _create_character
+function in order to set up your character object the account owns. But with the Evennia API,
+setting custom attributes is as easy as doing it in the meat of your Evennia game directory.
+
After all of this, our views.py file should look like something like this:
+
# file mygame/web/chargen/views.py
+
+fromdjango.shortcutsimportrender
+fromweb.chargen.modelsimportCharApp
+fromweb.chargen.formsimportAppForm
+fromdjango.httpimportHttpResponseRedirect
+fromdatetimeimportdatetime
+fromevennia.objects.modelsimportObjectDB
+fromdjango.confimportsettings
+fromevennia.utilsimportcreate
+
+defindex(request):
+ current_user=request.user# current user logged in
+ p_id=current_user.id# the account id
+ # submitted apps under this account
+ sub_apps=CharApp.objects.filter(account_id=p_id,submitted=True)
+ context={'sub_apps':sub_apps}
+ returnrender(request,'chargen/index.html',context)
+
+defdetail(request,app_id):
+ app=CharApp.objects.get(app_id=app_id)
+ name=app.char_name
+ background=app.background
+ submitted=app.submitted
+ p_id=request.user.id
+ context={'name':name,'background':background,
+ 'p_id':p_id,'submitted':submitted}
+ returnrender(request,'chargen/detail.html',context)
+
+defcreating(request):
+ user=request.user
+ ifrequest.method=='POST':
+ form=AppForm(request.POST)
+ ifform.is_valid():
+ name=form.cleaned_data['name']
+ background=form.cleaned_data['background']
+ applied_date=datetime.now()
+ submitted=True
+ if'save'inrequest.POST:
+ submitted=False
+ app=CharApp(char_name=name,background=background,
+ date_applied=applied_date,account_id=user.id,
+ submitted=submitted)
+ app.save()
+ ifsubmitted:
+ # Create the actual character object
+ typeclass=settings.BASE_CHARACTER_TYPECLASS
+ home=ObjectDB.objects.get_id(settings.GUEST_HOME)
+ # turn the permissionhandler to a string
+ perms=str(user.permissions)
+ # create the character
+ char=create.create_object(typeclass=typeclass,key=name,
+ home=home,permissions=perms)
+ user.db._playable_characters.append(char)
+ # add the right locks for the character so the account can
+ # puppet it
+ char.locks.add("puppet:id(%i) or pid(%i) or perm(Developers) "
+ "or pperm(Developers)"%(char.id,user.id))
+ char.db.background=background# set the character background
+ returnHttpResponseRedirect('/chargen')
+ else:
+ form=AppForm()
+ returnrender(request,'chargen/create.html',{'form':form})
+
You could change the format as you desire. To make it more secure, you could remove app_id from the
+“detail” url, and instead just fetch the account’s applications using a unifying field like
+account_id to find all the character application objects to display.
+
We must also update the main mygame/web/urls.py file (that is, one level up from our chargen app),
+so the main website knows where our app’s views are located. Find the patterns variable, and
+change it to include:
+
# in file mygame/web/urls.py
+
+fromdjango.conf.urlsimporturl,include
+
+# default evennia patterns
+fromevennia.web.urlsimporturlpatterns
+
+# eventual custom patterns
+custom_patterns=[
+ # url(r'/desired/url/', view, name='example'),
+]
+
+# this is required by Django.
+urlpatterns+=[
+ url(r'^chargen/',include('web.chargen.urls')),
+]
+
+urlpatterns=custom_patterns+urlpatterns
+
So we have our url patterns, views, and models defined. Now we must define our HTML templates that
+the actual user will see and interact with. For this tutorial we us the basic prosimii template
+that comes with Evennia.
+
Take note that we use user.is_authenticated to make sure that the user cannot create a character
+without logging in.
+
These files will all go into the /mygame/web/chargen/templates/chargen/ directory.
This HTML template should hold a list of all the applications the account currently has active. For
+this demonstration, we will only list the applications that the account has submitted. You could
+easily adjust this to include saved applications, or other types of applications if you have
+different kinds.
+
Please refer back to views.py to see where we define the variables these templates make use of.
This page should show a detailed character sheet of their application. This will only show their
+name and character background. You will likely want to extend this to show many more fields for your
+game. In a full-fledged character generation, you may want to extend the boolean attribute of
+submitted to allow accounts to save character applications and submit them later.
Our create HTML template will use the Django form we defined back in views.py/forms.py to drive the
+majority of the application process. There will be a form input for every field we defined in
+forms.py, which is handy. We have used POST as our method because we are sending information to the
+server that will update the database. As an alternative, GET would be much less secure. You can read
+up on documentation elsewhere on the web for GET vs. POST.
Once you have all these files stand in your mygame/folder and run:
+
evennia makemigrations
+evennia migrate
+
+
+
This will create and update the models. If you see any errors at this stage, read the traceback
+carefully, it should be relatively easy to figure out where the error is.
+
Login to the website (you need to have previously registered an Player account with the game to do
+this). Next you navigate to http://yourwebsite.com/chargen (if you are running locally this will
+be something like http://localhost:4001/chargen and you will see your new app in action.
+
This should hopefully give you a good starting point in figuring out how you’d like to approach your
+own web generation. The main difficulties are in setting the appropriate settings on your newly
+created character object. Thankfully, the Evennia API makes this easy.
+
+
+
Adding a no CAPCHA reCAPCHA on your character generation¶
+
As sad as it is, if your server is open to the web, bots might come to visit and take advantage of
+your open form to create hundreds, thousands, millions of characters if you give them the
+opportunity. This section shows you how to use the No CAPCHA reCAPCHA designed by Google. Not only is it
+easy to use, it is user-friendly… for humans. A simple checkbox to check, except if Google has
+some suspicion, in which case you will have a more difficult test with an image and the usual text
+inside. It’s worth pointing out that, as long as Google doesn’t suspect you of being a robot, this
+is quite useful, not only for common users, but to screen-reader users, to which reading inside of
+an image is pretty difficult, if not impossible. And to top it all, it will be so easy to add in
+your website.
The first thing is to ask Google for a way to safely authenticate your website to their service. To
+do it, we need to create a site key and a secret. Go to
+https://www.google.com/recaptcha/admin to create such a
+site key. It’s quite easy when you have a Google account.
+
When you have created your site key, save it safely. Also copy your secret key as well. You should
+find both information on the web page. Both would contain a lot of letters and figures.
+
+
+
Step 2: installing and configuring the dedicated Django app¶
+
Since Evennia runs on Django, the easiest way to add our CAPCHA and perform the proper check is to
+install the dedicated Django app. Quite easy:
+
pip install django-nocaptcha-recaptcha
+
+
+
And add it to the installed apps in your settings. In your mygame/server/conf/settings.py, you
+might have something like this:
Finally we have to add the CAPCHA to our form. It will be pretty easy too. First, open your
+web/chargen/forms.py file. We’re going to add a new field, but hopefully, all the hard work has
+been done for us. Update at your convenience, You might end up with something like this:
As you see, we added a line of import (line 2) and a field in our form.
+
And lastly, we need to update our HTML file to add in the Google library. You can open
+web/chargen/templates/chargen/create.html. There’s only one line to add:
And you should put it at the bottom of the page. Just before the closing body would be good, but
+for the time being, the base page doesn’t provide a footer block, so we’ll put it in the content
+block. Note that it’s not the best place, but it will work. In the end, your
+web/chargen/templates/chargen/create.html file should look like this:
Reload and open http://localhost:4001/chargen/create and
+you should see your beautiful CAPCHA just before the “submit” button. Try not to check the checkbox
+to see what happens. And do the same while checking the checkbox!
Before doing this tutorial you will probably want to read the intro in Basic Web tutorial.
+
In this tutorial we will create a web page that displays the stats of a game character. For this,
+and all other pages we want to make specific to our game, we’ll need to create our own Django “app”
+
We’ll call our app character, since it will be dealing with character information. From your game
+dir, run
+
evennia startapp character
+
+
+
This will create a directory named character in the root of your game dir. It contains all basic
+files that a Django app needs. To keep mygame well ordered, move it to your mygame/web/
+directory instead:
+
mv character web/
+
+
+
Note that we will not edit all files in this new directory, many of the generated files are outside
+the scope of this tutorial.
+
In order for Django to find our new web app, we’ll need to add it to the INSTALLED_APPS setting.
+Evennia’s default installed apps are already set, so in server/conf/settings.py, we’ll just extend
+them:
+
INSTALLED_APPS+=('web.character',)
+
+
+
+
Note: That end comma is important. It makes sure that Python interprets the addition as a tuple
+instead of a string.
+
+
The first thing we need to do is to create a view and an URL pattern to point to it. A view is a
+function that generates the web page that a visitor wants to see, while the URL pattern lets Django
+know what URL should trigger the view. The pattern may also provide some information of its own as
+we shall see.
+
Here is our character/urls.py file (Note: you may have to create this file if a blank one
+wasn’t generated for you):
+
# URL patterns for the character app
+
+fromdjango.conf.urlsimporturl
+fromweb.character.viewsimportsheet
+
+urlpatterns=[
+ url(r'^sheet/(?P<object_id>\d+)/$',sheet,name="sheet")
+]
+
+
+
This file contains all of the URL patterns for the application. The url function in the
+urlpatterns list are given three arguments. The first argument is a pattern-string used to
+identify which URLs are valid. Patterns are specified as regular expressions. Regular expressions
+are used to match strings and are written in a special, very compact, syntax. A detailed description
+of regular expressions is beyond this tutorial but you can learn more about them
+here. For now, just accept that this regular
+expression requires that the visitor’s URL looks something like this:
+
sheet/123/
+
+
+
That is, sheet/ followed by a number, rather than some other possible URL pattern. We will
+interpret this number as object ID. Thanks to how the regular expression is formulated, the pattern
+recognizer stores the number in a variable called object_id. This will be passed to the view (see
+below). We add the imported view function (sheet) in the second argument. We also add the name
+keyword to identify the URL pattern itself. You should always name your URL patterns, this makes
+them easy to refer to in html templates using the {%url%} tag (but we won’t get more into that
+in this tutorial).
+
+
Security Note: Normally, users do not have the ability to see object IDs within the game (it’s
+restricted to superusers only). Exposing the game’s object IDs to the public like this enables
+griefers to perform what is known as an account enumeration
+attack in the efforts of
+hijacking your superuser account. Consider this: in every Evennia installation, there are two
+objects that we can always expect to exist and have the same object IDs– Limbo (#2) and the
+superuser you create in the beginning (#1). Thus, the griefer can get 50% of the information they
+need to hijack the admin account (the admin’s username) just by navigating to sheet/1!
+
+
Next we create views.py, the view file that urls.py refers to.
+
# Views for our character app
+
+fromdjango.httpimportHttp404
+fromdjango.shortcutsimportrender
+fromdjango.confimportsettings
+
+fromevennia.utils.searchimportobject_search
+fromevennia.utils.utilsimportinherits_from
+
+defsheet(request,object_id):
+ object_id='#'+object_id
+ try:
+ character=object_search(object_id)[0]
+ exceptIndexError:
+ raiseHttp404("I couldn't find a character with that ID.")
+ ifnotinherits_from(character,settings.BASE_CHARACTER_TYPECLASS):
+ raiseHttp404("I couldn't find a character with that ID. "
+ "Found something else instead.")
+ returnrender(request,'character/sheet.html',{'character':character})
+
+
+
As explained earlier, the URL pattern parser in urls.py parses the URL and passes object_id to
+our view function sheet. We do a database search for the object using this number. We also make
+sure such an object exists and that it is actually a Character. The view function is also handed a
+request object. This gives us information about the request, such as if a logged-in user viewed it
+
+
we won’t use that information here but it is good to keep in mind.
+
+
On the last line, we call the render function. Apart from the request object, the render
+function takes a path to an html template and a dictionary with extra data you want to pass into
+said template. As extra data we pass the Character object we just found. In the template it will be
+available as the variable “character”.
+
The html template is created as templates/character/sheet.html under your character app folder.
+You may have to manually create both template and its subfolder character. Here’s the template
+to create:
In Django templates, {%...%} denotes special in-template “functions” that Django understands.
+The {{...}} blocks work as “slots”. They are replaced with whatever value the code inside the
+block returns.
+
The first line, {%extends"base.html"%}, tells Django that this template extends the base
+template that Evennia is using. The base template is provided by the theme. Evennia comes with the
+open-source third-party theme prosimii. You can find it and its base.html in
+evennia/web/templates/prosimii. Like other templates, these can be overwritten.
+
The next line is {%blockcontent%}. The base.html file has blocks, which are placeholders
+that templates can extend. The main block, and the one we use, is named content.
+
We can access the character variable anywhere in the template because we passed it in the render
+call at the end of view.py. That means we also have access to the Character’s db attributes,
+much like you would in normal Python code. You don’t have the ability to call functions with
+arguments in the template– in fact, if you need to do any complicated logic, you should do it in
+view.py and pass the results as more variables to the template. But you still have a great deal of
+flexibility in how you display the data.
+
We can do a little bit of logic here as well. We use the {%for%}...{%endfor%} and {%if%}...{%else%}...{%endif%} structures to change how the template renders depending on how many
+skills the user has, or if the user is approved (assuming your game has an approval system).
+
The last file we need to edit is the master URLs file. This is needed in order to smoothly integrate
+the URLs from your new character app with the URLs from Evennia’s existing pages. Find the file
+web/urls.py and update its patterns list as follows:
Now reload the server with evenniareload and visit the page in your browser. If you haven’t
+changed your defaults, you should be able to find the sheet for character #1 at
+http://localhost:4001/character/sheet/1/
+
Try updating the stats in-game and refresh the page in your browser. The results should show
+immediately.
+
As an optional final step, you can also change your character typeclass to have a method called
+‘get_absolute_url’.
Doing so will give you a ‘view on site’ button in the top right of the Django Admin Objects
+changepage that links to your new character sheet, and allow you to get the link to a character’s
+page by using in any template where you have a given object.
+
Now that you’ve made a basic page and app with Django, you may want to read the full Django
+tutorial to get a better idea of what it can do. You can find Django’s tutorial
+here.
The Evennia website is a Django application that ties in with the MUD database. Since the website
+shares this database you could, for example, tell website visitors how many accounts are logged into
+the game at the moment, how long the server has been up and any other database information you may
+want. During development you can access the website by pointing your browser to
+http://localhost:4001.
+
+
You may also want to set DEBUG=True in your settings file for debugging the website. You will
+then see proper tracebacks in the browser rather than just error codes. Note however that this will
+leak memory a lot (it stores everything all the time) and is not to be used in production. It’s
+recommended to only use DEBUG for active web development and to turn it off otherwise.
+
+
A Django (and thus Evennia) website basically consists of three parts, a
+view an associated
+template and an urls.py file. Think of
+the view as the Python back-end and the template as the HTML files you are served, optionally filled
+with data from the back-end. The urls file is a sort of mapping that tells Django that if a specific
+URL is given in the browser, a particular view should be triggered. You are wise to review the
+Django documentation for details on how to use these components.
+
Evennia’s default website is located in
+evennia/web/website. In this
+folder you’ll find the simple default view as well as subfolders templates and static. Static
+files are things like images, CSS files and Javascript.
You customize your website from your game directory. In the folder web you’ll find folders
+static, templates, static_overrides and templates_overrides. The first two of those are
+populated automatically by Django and used to serve the website. You should not edit anything in
+them - the change will be lost. To customize the website you’ll need to copy the file you want to
+change from the web/website/template/ or web/website/static/ path to the corresponding place
+under one of _overrides directories.
+
Example: To override or modify evennia/web/website/template/website/index.html you need to
+add/modify mygame/web/template_overrides/website/index.html.
+
The detailed description on how to customize the website is best described in tutorial form. See the
+Web Tutorial for more information.
The Python backend for every HTML page is called a Django
+view. A view can do all sorts of
+functions, but the main one is to update variables data that the page can display, like how your
+out-of-the-box website will display statistics about number of users and database objects.
+
To re-point a given page to a view.py of your own, you need to modify mygame/web/urls.py. An
+URL pattern is a regular
+expression that you need to enter in the address
+field of your web browser to get to the page in question. If you put your own URL pattern before
+the default ones, your own view will be used instead. The file urls.py even marks where you should
+put your change.
+
Here’s an example:
+
# mygame/web/urls.py
+
+fromdjango.conf.urlsimporturl,include
+# default patterns
+fromevennia.web.urlsimporturlpatterns
+
+# our own view to use as a replacement
+fromweb.myviewsimportmyview
+
+# custom patterns to add
+patterns=[
+ # overload the main page view
+ url(r'^',myview,name='mycustomview'),
+]
+
+urlpatterns=patterns+urlpatterns
+
+
+
+
Django will always look for a list named urlpatterns which consists of the results of url()
+calls. It will use the first match it finds in this list. Above, we add a new URL redirect from
+the root of the website. It will now our own function myview from a new module
+mygame/web/myviews.py.
+
+
If our game is found on http://mygame.com, the regular expression "^" means we just entered
+mygame.com in the address bar. If we had wanted to add a view for http://mygame.com/awesome, the
+regular expression would have been ^/awesome.
+
+
Look at evennia/web/website/views.py to see the inputs and outputs you must have to define a view. Easiest may be to
+copy the default file to mygame/web to have something to modify and expand on.
+
Restart the server and reload the page in the browser - the website will now use your custom view.
+If there are errors, consider turning on settings.DEBUG to see the full tracebacks - in debug mode
+you will also log all requests in mygame/server/logs/http_requests.log.
Evennia comes with a MUD client accessible from a normal web browser. During
+development you can try it at http://localhost:4001/webclient.
+See the Webclient page for more details.
Django comes with a built-in admin
+website. This is accessible by clicking
+the ‘admin’ button from your game website. The admin site allows you to see, edit and create objects
+in your database from a graphical interface.
+
The behavior of default Evennia models are controlled by files admin.py in the Evennia package.
+New database models you choose to add yourself (such as in the Web Character View Tutorial) can/will
+also have admin.py files. New models are registered to the admin website by a call of
+admin.site.register(modelclass,adminclass) inside an admin.py file. It is an error to attempt
+to register a model that has already been registered.
+
To overload Evennia’s admin files you don’t need to modify Evennia itself. To customize you can call
+admin.site.unregister(modelclass), then follow that with admin.site.register in one of your own
+admin.py files in a new app that you add.
Evennia relies on Django for its web features. For details on expanding your web experience, the
+Django documentation or the Django
+Book are the main resources to look into. In Django
+lingo, the Evennia is a django “project” that consists of Django “applications”. For the sake of web
+implementation, the relevant django “applications” in default Evennia are web/website or
+web/webclient.
Evennia uses the Django web framework as the basis of both its
+database configuration and the website it provides. While a full understanding of Django requires
+reading the Django documentation, we have provided this tutorial to get you running with the basics
+and how they pertain to Evennia. This text details getting everything set up. The Web-based
+Character view Tutorial gives a more explicit example of making a
+custom web page connected to your game, and you may want to read that after finishing this guide.
Django is a web framework. It gives you a set of development tools for building a website quickly
+and easily.
+
Django projects are split up into apps and these apps all contribute to one project. For instance,
+you might have an app for conducting polls, or an app for showing news posts or, like us, one for
+creating a web client.
+
Each of these applications has a urls.py file, which specifies what
+URLs are used by the app, a views.py file
+for the code that the URLs activate, a templates directory for displaying the results of that code
+in HTML for the user, and a static folder that holds assets
+like CSS, Javascript,
+and Image files (You may note your mygame/web folder does not have a static or template folder.
+This is intended and explained further below). Django applications may also have a models.py file
+for storing information in the database. We “cmd: attr(locktest, 80, compare=gt)”will not change any
+models here, take a look at the New Models page (as well as the Django
+docs on models) if you are interested.
+
There is also a root urls.py that determines the URL structure for the entire project. A starter
+urls.py is included in the default game template, and automatically imports all of Evennia’s
+default URLs for you. This is located in web/urls.py.
Evennia’s default logo is a fun little googly-eyed snake wrapped around a gear globe. As cute as it
+is, it probably doesn’t represent your game. So one of the first things you may wish to do is
+replace it with a logo of your own.
+
Django web apps all have static assets: CSS files, Javascript files, and Image files. In order to
+make sure the final project has all the static files it needs, the system collects the files from
+every app’s static folder and places it in the STATIC_ROOT defined in settings.py. By default,
+the Evennia STATIC_ROOT is in web/static.
+
Because Django pulls files from all of those separate places and puts them in one folder, it’s
+possible for one file to overwrite another. We will use this to plug in our own files without having
+to change anything in the Evennia itself.
+
By default, Evennia is configured to pull files you put in the web/static_overridesafter all
+other static files. That means that files in static_overrides folder will overwrite any previously
+loaded files having the same path under its static folder. This last part is important to repeat:
+To overload the static resource from a standard static folder you need to replicate the path of
+folders and file names from that static folder in exactly the same way inside static_overrides.
+
Let’s see how this works for our logo. The default web application is in the Evennia library itself,
+in evennia/web/. We can see that there is a static folder here. If we browse down, we’ll
+eventually find the full path to the Evennia logo file:
+evennia/web/website/static/website/images/evennia_logo.png.
+
Inside our static_overrides we must replicate the part of the path inside the website’s static
+folder, in other words, we must replicate website/images/evennia_logo.png.
+
So, to change the logo, we need to create the folder path website/images/ in static_overrides.
+You may already have this folder structure prepared for you. We then rename our own logo file to
+evennia_logo.png and copy it there. The final path for this file would thus be:
+mygame/web/static_overrides/website/images/evennia_logo.png.
+
To get this file pulled in, just change to your own game directory and reload the server:
+
evenniareload
+
+
+
This will reload the configuration and bring in the new static file(s). If you didn’t want to reload
+the server you could instead use
+
evenniacollectstatic
+
+
+
to only update the static files without any other changes.
+
+
Note: Evennia will collect static files automatically during startup. So if evenniacollectstatic reports finding 0 files to collect, make sure you didn’t start the engine at some
+point - if so the collector has already done its work! To make sure, connect to the website and
+check so the logo has actually changed to your own version.
+
+
+
Note: Sometimes the static asset collector can get confused. If no matter what you do, your
+overridden files aren’t getting copied over the defaults, try removing the target file (or
+everything) in the web/static directory, and re-running collectstatic to gather everything from
+scratch.
The default front page for Evennia contains information about the Evennia project. You’ll probably
+want to replace this information with information about your own project. Changing the page template
+is done in a similar way to changing static resources.
+
Like static files, Django looks through a series of template folders to find the file it wants. The
+difference is that Django does not copy all of the template files into one place, it just searches
+through the template folders until it finds a template that matches what it’s looking for. This
+means that when you edit a template, the changes are instant. You don’t have to reload the server or
+run any extra commands to see these changes - reloading the web page in your browser is enough.
+
To replace the index page’s text, we’ll need to find the template for it. We’ll go into more detail
+about how to determine which template is used for rendering a page in the Web-based Character view
+Tutorial. For now, you should know that the template we want to change
+is stored in evennia/web/website/templates/website/index.html.
+
To replace this template file, you will put your changed template inside the
+web/template_overrides/website directory in your game folder. In the same way as with static
+resources you must replicate the path inside the default template directory exactly. So we must
+copy our replacement template named index.html there (or create the website directory in
+web/template_overridesifitdoesnotexist,first).Thefinalpathtothefileshouldthusbe:web/template_overrides/website/index.html` within your game directory.
+
Note that it is usually easier to just copy the original template over and edit it in place. The
+original file already has all the markup and tags, ready for editing.
For further hints on working with the web presence, you could now continue to the Web-based
+Character view Tutorial where you learn to make a web page that
+displays in-game character stats. You can also look at Django’s own
+tutorial to get more insight in how Django works and what
+possibilities exist.
Griatch (IRC)APP @friarzen: Could one (with goldenlayout) programmatically provide pane positions
+and sizes?
+I recall it was not trivial for the old split.js solution.
+ friarzen take a look at the goldenlayout_default_config.js
+It is kinda cryptic but that is the layout json.
+ Griatch (IRC)APP @friarzen: Hm, so dynamically replacing the goldenlayout_config in the global
+scope at the right
+ thing would do it?
+ friarzen yep
+ friarzen the biggest pain in the butt is that goldenlayout_config needs to be set before the
+goldenlayout init()
+is called, which isn't trivial with the current structure, but it isn't impossible.
+ Griatch (IRC)APP One could in principle re-run init() at a later date though, right?
+ friarzen Hmm...not sure I've ever tried it... seems doable off the top of my head...
+right now, that whole file exists to be overridden on page load as a separate <script> HTML tag, so
+it can get
+force-loaded early.
+you could just as easily call the server at that time for the proper config info and store it into
+the variable.
+ Griatch (IRC)APP @friarzen: Right. I picture the default would either be modified directly in
+that file or even be
+in settings.
+ friarzen And if you have it call with an authenticated session at that early point, you could
+even have the layout
+be defined as a per-user setting.
+ Griatch (IRC)APP Yeah, I think that'd be a very important feature.
+So one part of the config would be the golden-layout config blob; then another section with custom
+per-pane
+settings. Things like which tag a pane filters on, its type, and options for that type (like
+replace/scroll for a
+text pane etc).
+And probably one general config section; things like notifications, sounds, popup settings and the
+like
+ friarzen Actually, that information is already somewhat stored into the data as componentState {}
+ Griatch (IRC)APP I imagine a pane would need to be identified uniquely for golden-layout to know
+which is which,
+so that'd need to be associated.
+ friarzen see line 55.
+that's....where it gets tricky...
+goldenlayout is kinda dumb in that it treats the whole json blob as a state object.
+ Griatch (IRC)APP componentState, is the idea that it takes arbitrary settings then?
+ friarzen so each sub-dictionary with a type of "component" in it is a window and as you move
+stuff around it
+dynamically creates new dictionaries as needed to keep it all together.
+yep
+right now I'm storing the list of types as a string and the updateMethod type as a string, just to
+be as obvious as
+possible.
+ Griatch (IRC)APP I wonder if one could populate componentState(s) just before initializing the
+system, and then
+extract them into a more Evennia-friendly storage when saving. Or maybe it's no big deal to just
+store that whole
+blob as-is, with some extra things added?
+ friarzen if you want to see it in action, take a look at the values in your localstorage after
+you've moved stuff
+around, created new windows, etc.
+I think their preference is to just store the whole blob.
+which is what the localstorage save does, in fact.
+ Griatch (IRC)APP Yes, I see.
+It allows you to retain the session within your browser as well
+ friarzen One trick I've been thinking about for the whole interface is to have another pair of
+components, one
+being a "dynamic" config window that displays a series of dropdowns, one for each plugin that
+defines it's own
+settings.
+And another that has an embedded i-frame to allow a split-screen where half of the display is the
+text interface
+and the other loads the local main website page and allows further browsing.
+ Griatch (IRC)APP I think ideally, each pane should handle its per-pane settings as a dropdown or
+popup triggered
+from its header.
+ friarzen well, pane != plugin...
+ Griatch (IRC)APP True, one tends to sometimes make the other available but they are not the same
+ friarzen think of the history plugin for example...if you want to make keyboard keys dynamically
+configurable, you
+need some place to enter that information.
+yeah.
+ Griatch (IRC)APP Yes, I buy that - a dynamical option window would be needed. I'd say the
+'global' settings should
+be treated as just another module in this regard though
+So that if you have no modules installed, there is one entry in that option pane, the one that opens
+the global
+options
+ friarzen Yeah, so that is that component...one pane.
+ Griatch (IRC)APP Another thing that the config should contain would be the available message
+tags. Waiting for
+them to actually show up before being able to use them is not that useful. This would likely have to
+be a setting
+though I imagine.
+ friarzen we could remove the current pop-up and just open the new component pane instead, and
+then that pane would
+display the list of plugins that registered a config() callback.
+ Griatch (IRC)APP Yes
+ friarzen yeah, the server has to pre-define what tags it is going to send.
+ Griatch (IRC)APP The process for adding a tag would be to adding to, say, a list in settings,
+restart and then .. profit
+ friarzen yep, which is kind of how I see spawns working.
+ Griatch (IRC)APP spawns, meaning stand-alone windows?
+ friarzen we just have a plugin that defines it's config pane with a "match this text" box and a
+tag type to send
+the data to, or spawn a new pane with that tag type preselected to capture that text.
+wouldn't be stand alone windows. just new tabs, for now.
+ Griatch (IRC)APP Ok, so a filter target that filters on text content and directs to tag
+ friarzen yep.
+ Griatch (IRC)APP (or re-tags it, I suppose)
+ friarzen yeah, exactly.
+and opens a new window for that tag if one doesn't already exist.
+ Griatch (IRC)APP A lot of complex effects could be achieved there. Should the filter extract the
+text and re-tag,
+or should it keep the text with its original tag and make a copy of it with the new tag? O_o;
+ friarzen yet more options for the user. :slightly_smiling_face:
+baby steps first I think.
+"pages from bob should go to bob's window, not the general chat window"
+ Griatch (IRC)APP It doesn't sound too hard to do - using tagging like that is really neat since
+then the rerouting
+can just work normally without knowing which pane you are actually rerouting too (and you could have
+multiple panes
+getting the same content too)
+ friarzen yep.
+and the i-frame component, I think, provides just as much wow facter.
+ Griatch (IRC)APP Yes, the setting would be something like: If text contains "bob" -> set tag
+"bob" (auto-create
+pane if not exist []?)
+ friarzen just being able to load a character page from the wiki side in half the screen
+(including the pictures
+and whatnot) while playing/reading the text from the other half is really nice.
+ Griatch (IRC)APP Could there not be some cross-scripting warnings in modern browsers if trying to
+load another
+website in an iframe?
+I doubt you could open Github like that, for example.
+friarzen well, assuming it is all from the same origin game server, it will all be from the same
+domain, so should
+avoid that, but it will take some testing to make sure. I want to get it to the point where you can
+click on
+somebody's name in the general terminal-style window and have it pop up that characters' wiki page.
+ Griatch (IRC)APP That does sound nice :)
+ friarzen i-frames are pretty much the only way to handle cross-domain content, but they are a
+pain in the butt to
+get that to work.
+ Griatch (IRC)APP If it's on the same domain it would be fine, but it should probably give a "you
+are now leaving
+..." message and switch to a new window if going elsewhere.
+ friarzen (without getting into modern CORS url blessings)
+yeah
+ Griatch (IRC)APP Just to avoid headaches :)
+ friarzen So, yeah, two new goldenlayout components that I am working on but they aren't ready
+yet. heh.
+ Griatch (IRC)APP Oh, you are working on them already?
+ friarzen yeah, some initial "will this work" structure.
+I haven't found anything yet that will not make it work.
+it's mostly just not going to look very pretty to start with. It'll take a few iterations I bet.
+ Griatch (IRC)APP Sounds good! We should probably try to formalize our thoughts around a future
+config format as
+well. Depending just how and when the golden-layout blob needs to be in place and if we can
+construct it on-the-
+fly, it affects the format style chosen.
+ friarzen Yeah, that's new from today's conversation, so I don't really have anything built for
+that.
+ Griatch (IRC)APP I'm still unsure about how the Evennia/pane specific things (that'd need to be
+serialized to the
+database on a per-account basis) would interact with the golden-layout structure.
+ friarzen maybe the place to start is to wipe-out/update the old
+https://github.com/evennia/evennia/wiki/Webclient-
+brainstorm entry?
+ Griatch (IRC)APP I don't think we need to wipe the old, but we could certainly add a new section
+above the old and
+start some new discussions.
+ friarzen It is really just that per componentState bit.
+anything in that json is treated as a blackbox that goldenlayout just passes around.
+ Griatch (IRC)APP So would we save the whole blob then, componentState and all, and simply not
+worry about
+ourselves storing per-pane options?
+The drawback with that would be that a dev could not offer an exact default layout/setup for the
+user.
+... unless they set it up and saved the blob I guess
+ friarzen Yeah, that's exactly how I built mine. :slightly_smiling_face:
+not the most dev-friendly way to do it, but it works.
+ Griatch (IRC)APP Heh. Ok, so the config would be one section for the golden-layout-blob, one for
+module settings,
+one of which is the global settings.
+and a list of the available tags
+ friarzen yep
+ Griatch (IRC)APP And probably an empty misc section for future use ...
+ friarzen seems reasonable.
+ Griatch (IRC)APP So, that means that in the future one would need a procedure for the dev to
+easily create and
+save the player-wide default to central storage. Well, one step at a time.
+For now, good night!
+ friarzen Yep, I expect that would be some kind of admin-approved api/command
+ Griatch (IRC)APP And thanks for the discussion!
+
These are my ideas for the functionality of Evennia’s webclient in the (possibly distant) future. It
+assumes the webclient options have been implemented as per
+#1172.
+
+
The idea of the GUI is based around panes (a “window” feels like something that pops open). These
+are areas of the window of the type you would see from a javascript library like
+Split.js. Each pane has a separator with a handle for
+resizing it.
+
Each pane has an icon for opening a dropdown menu (see next mockup image).
+
Above image could show the default setup; mimicking the traditional telnet mud client setup. There
+should be at least one input pane (but you could add multiple). The Input pane has the icon for
+launching the main webclient options window. Alternatively the options icon could hover
+transparently in the upper left, “above” all panes.
+
The main webclient options window should have an option for hiding all GUI customization icons (the
+draggable handles of panes and the dropdown menu) for users who does not want to have to worry about
+changing things accidentally. Devs not wanting to offer players the ability to customize their
+client GUIs could maybe just set it up the way they want and then turn the gui controls off on the
+javascript level.
+
+
The dropdown menu allows for splitting each pane horizontally and vertically.
+
+
Each pane is “tagged” with what data will be sent to it. It could accept more than one type of
+tagged data. Just which tags are available is different for each game (and should be reported by the
+server).
+
For example, the server could send the return of the “look” command as
+
msg("you see ...",{"pane":"look"})
+
+
+
If one (or more) of the panes is set to receive “look” data, all such will go there. If no pane is
+assigned to “look”, this text will end up in the pane(s) with the “Unassigned” designation. It might
+be necessary to enforce that at least one window has the “unassigned” designation in order to not
+lose output without a clear pane target. By contrast the pane tagged with “All” receives all output
+regardless of if it is also being sent to other panes.
+
Another thing that came to mind is logging. It might be an idea to tie a given log file to a
+specific pane (panes?) to limit spam in the log (it might be one reason for having a pane with the
+“All” tag, for those wanting to log everything). This could also be set in the dropdown menu or in
+the webclient options window, maybe.
That way of splitting panes seems easy to manage while still being powerful at the same time.
+It’s certainly a lot more flexible than what I had in mind.
+
This is a quick sketch of what I was thinking about:
The panes themselves can work differently depending on the content though:
+
+
channel messages: when someone talks on a channel, the output text needs to be appended to the
+text already shown.
+
inventory: this pane should clear its output every time the inventory is displayed, so old
+inventory is not displayed. Also what about dropping items? As long as the player doesn’t check its
+inventory then that pane won’t be updated, unless more OOB data is added to track the inventory.
+
help/look: those pane should probably also clear their content before displaying new content.
+
+
logging: do you mean have a “save to log” item in the pane menu?
It makes sense that different types of panes would have different functionality. I was thinking that
+something like inventory would be very specific to a given game. But titeuf87 has a point - maybe
+you can get a rather generalized behavior just by defining if a pane should replace or append to the
+existing data.
+
As for the updating of inventory when dropping items, not sure if that can be generalized, but I
+guess drop/get commands could be wired to do that.
+
As for logging - yes I picture a “save to log” thing in the menu. The problem is just where to save
+the log. I think at least for an initial setup, one could maybe set the logging file path in the
+webclient options and just append logs from all the panes marked for logging to that same file.
I’ve been messing a little bit with split.js - I’ve managed to split a split dynamically with a data
+attribute on a button and jQuery. My current thinking on this issue is to use jQuery
+Templates to write the template for horizontal and
+vertical splits, then using jQuery to set up the initial split layout by inserting those templates
+as appropriate for user settings.
+
We would have a dropdown per split that decides what tag it’s meant to handle - perhaps a data
+attribute that we change in the template for easy selection through jQuery - and then send tags for
+messages to the webclient. This dropdown would also have a setting per split to either append new
+content or replace content - again, perhaps a data attribute.
+
We might also want to store “mobile” and “desktop” layouts separately - or just default to a mobile-
+friendly layout at a certain screen size, and turn off the splits.
+
Oh, and embedding Bootstrap containers in splits works perfectly - that could help us too.
I’ve got a demo of this working at my development box,
+if anyone wants to take a look. So far, I’ve got tag handling, dynamic splits, and the ability to
+swap splits’ contents. Since this doesn’t have a fancy interface yet, if you want to see a dynamic
+split, open your browser’s console and try these commands:
+
SplitHandler.split("#main","horizontal") will split the top-half into two 50-50 splits, both
+receiving content that’s not tagged or is tagged “all” from the server.
+
SplitHandler.swapContent("#input","#split_2") after doing so will swap the input into the top-
+left split.
+
I’m trying to figure out where to put each split’s configuration - maybe a dropdown button that’s
+semi-transparent until you hover over it? So far, if you edited the data attributes, you could
+change each split to receive only tagged messages ( data-tag=“all” ), and you could change them to
+overwrite instead of append data ( data-update-append or data-update-overwrite )
I would suggest that, assuming the game dev allows the user to modify their GUI in the first place,
+that modification is a “mode”. I picture that 99% of the time a user doesn’t need to modify their
+interface. They only want to click whatever game-related buttons etc are present in the pane without
+risk of resizing things accidentally.
+
So I’d say that normally the panes are all fixed, with minimal spacing between them, no handles etc.
+But you can enter the client settings window and choose Customize GUI mode (or something like
+that). When you do, then separators get handles and the dropdown menu markers appear (permanently)
+in the corner of each pane. This means that if a user wants to easily tweak their window they
+could stay in this mode, but they could also “lock” the gui layout at any time.
Evennia comes with a MUD client accessible from a normal web browser. During development you can try
+it at http://localhost:4001/webclient. The client consists of several parts, all under
+evennia/web/webclient/:
+
templates/webclient/webclient.html and templates/webclient/base.html are the very simplistic
+django html templates describing the webclient layout.
+
static/webclient/js/evennia.js is the main evennia javascript library. This handles all
+communication between Evennia and the client over websockets and via AJAX/COMET if the browser can’t
+handle websockets. It will make the Evennia object available to the javascript namespace, which
+offers methods for sending and receiving data to/from the server transparently. This is intended to
+be used also if swapping out the gui front end.
+
static/webclient/js/webclient_gui.js is the default plugin manager. It adds the plugins and
+plugin_manager objects to the javascript namespace, coordinates the GUI operations between the
+various plugins, and uses the Evennia object library for all in/out.
+
static/webclient/js/plugins provides a default set of plugins that implement a “telnet-like”
+interface.
+
static/webclient/css/webclient.css is the CSS file for the client; it also defines things like how
+to display ANSI/Xterm256 colors etc.
+
The server-side webclient protocols are found in evennia/server/portal/webclient.py and
+webclient_ajax.py for the two types of connections. You can’t (and should not need to) modify
+these.
Like was the case for the website, you override the webclient from your game directory. You need to
+add/modify a file in the matching directory location within one of the _overrides directories.
+These _override directories are NOT directly used by the web server when the game is running, the
+server copies everything web related in the Evennia folder over to mygame/web/static/ and then
+copies in all of your _overrides. This can cause some cases were you edit a file, but it doesn’t
+seem to make any difference in the servers behavior. Before doing anything else, try shutting
+down the game and running evenniacollectstatic from the command line then start it back up, clear
+your browser cache, and see if your edit shows up.
+
Example: To change the utilized plugin list, you need to override base.html by copying
+evennia/web/webclient/templates/webclient/base.html to
+mygame/web/template_overrides/webclient/base.html and editing it to add your new plugin.
booleanonKeydown(event) This plugin listens for Keydown events
+
onBeforeUnload() This plugin does something special just before the webclient page/tab is
+closed.
+
onLoggedIn(args,kwargs) This plugin does something when the webclient first logs in.
+
onGotOptions(args,kwargs) This plugin does something with options sent from the server.
+
booleanonText(args,kwargs) This plugin does something with messages sent from the server.
+
booleanonPrompt(args,kwargs) This plugin does something when the server sends a prompt.
+
booleanonUnknownCmd(cmdname,args,kwargs) This plugin does something with “unknown commands”.
+
onConnectionClose(args,kwargs) This plugin does something when the webclient disconnects from
+the server.
+
newstringonSend(string) This plugin examines/alters text that other plugins generate. Use
+with caution
+
+
The order of the plugins defined in base.html is important. All the callbacks for each plugin
+will be executed in that order. Functions marked “boolean” above must return true/false. Returning
+true will short-circuit the execution, so no other plugins lower in the base.html list will have
+their callback for this event called. This enables things like the up/down arrow keys for the
+history.js plugin to always occur before the default_in.js plugin adds that key to the current input
+buffer.
clienthelp.js Defines onOptionsUI from the options2 plugin. This is a mostly empty plugin to
+add some “How To” information for your game.
+
default_in.js Defines onKeydown. key or mouse clicking the arrow will send the currently
+typed text.
+
default_out.js Defines onText, onPrompt, and onUnknownCmd. Generates HTML output for the user.
+
default_unload.js Defines onBeforeUnload. Prompts the user to confirm that they meant to
+leave/close the game.
+
font.js Defines onOptionsUI. The plugin adds the ability to select your font and font size.
+
goldenlayout_default_config.js Not actually a plugin, defines a global variable that
+goldenlayout uses to determine its window layout, known tag routing, etc.
+
goldenlayout.js Defines onKeydown, onText and custom functions. A very powerful “tabbed” window
+manager for drag-n-drop windows, text routing and more.
+
history.js Defines onKeydown and onSend. Creates a history of past sent commands, and uses arrow
+keys to peruse.
+
hotbuttons.js Defines onGotOptions. A Disabled-by-default plugin that defines a button bar with
+user-assignable commands.
+
iframe.js Defines onOptionsUI. A goldenlayout-only plugin to create a restricted browsing sub-
+window for a side-by-side web/text interface, mostly an example of how to build new HTML
+“components” for goldenlayout.
+
message_routing.js Defines onOptionsUI, onText, onKeydown. This goldenlayout-only plugin
+implements regex matching to allow users to “tag” arbitrary text that matches, so that it gets
+routed to proper windows. Similar to “Spawn” functions for other clients.
+
multimedia.js An basic plugin to allow the client to handle “image” “audio” and “video” messages
+from the server and display them as inline HTML.
+
notifications.js Defines onText. Generates browser notification events for each new message
+while the tab is hidden.
+
oob.js Defines onSend. Allows the user to test/send Out Of Band json messages to the server.
+
options.js Defines most callbacks. Provides a popup-based UI to coordinate options settings with
+the server.
+
options2.js Defines most callbacks. Provides a goldenlayout-based version of the
+options/settings tab. Integrates with other plugins via the custom onOptionsUI callback.
+
popups.js Provides default popups/Dialog UI for other plugins to use.
So, you love the functionality of the webclient, but your game has specific types of text that need
+to be separated out into their own space, visually. The Goldenlayout plugin framework can help with
+this.
GoldenLayout is a web framework that allows web developers and their users to create their own
+tabbed/windowed layouts. Windows/tabs can be click-and-dragged from location to location by
+clicking on their titlebar and dragging until the “frame lines” appear. Dragging a window onto
+another window’s titlebar will create a tabbed “Stack”. The Evennia goldenlayout plugin defines 3
+basic types of window: The Main window, input windows and non-main text output windows. The Main
+window and the first input window are unique in that they can’t be “closed”.
+
The most basic customization is to provide your users with a default layout other than just one Main
+output and the one starting input window. This is done by modifying your server’s
+goldenlayout_default_config.js.
+
Start by creating a new
+mygame/web/static_overrides/webclient/js/plugins/goldenlayout_default_config.js file, and adding
+the following JSON variable:
This is a bit ugly, but hopefully, from the indentation, you can see that it creates a side-by-side
+(2-column) interface with 3 windows down the left side (The Main and 2 inputs) and a pair of windows
+on the right side for extra outputs. Any text tagged with “some-tag-here” will flow to the bottom
+of the “example” window, and any text tagged “sheet” will replace the text already in the “sheet”
+window.
+
Note: GoldenLayout gets VERY confused and will break if you create two windows with the “Main”
+componentName.
+
Now, let’s say you want to display text on each window using different CSS. This is where new
+goldenlayout “components” come in. Each component is like a blueprint that gets stamped out when
+you create a new instance of that component, once it is defined, it won’t be easily altered. You
+will need to define a new component, preferably in a new plugin file, and then add that into your
+page (either dynamically to the DOM via javascript, or by including the new plugin file into the
+base.html).
+
First up, follow the directions in Customizing the Web Client section above to override the
+base.html.
+
Next, add the new plugin to your copy of base.html:
[Parsing command arguments, theory and best practices](Parsing-command-arguments,-theory-and-best-
+practices) - Parsing-command-arguments-best-practices
+
Portal and server - The-two-processes-that-make-up-the-running-Evennia-
+server-are-described.
Say you create a room named Meadow in your nice big forest MUD. That’s all nice and dandy, but
+what if you, in the other end of that forest want another Meadow? As a game creator, this can
+cause all sorts of confusion. For example, teleporting to Meadow will now give you a warning that
+there are two Meadow s and you have to select which one. It’s no problem to do that, you just
+choose for example to go to 2-meadow, but unless you examine them you couldn’t be sure which of
+the two sat in the magical part of the forest and which didn’t.
+
Another issue is if you want to group rooms in geographic regions. Let’s say the “normal” part of
+the forest should have separate weather patterns from the magical part. Or maybe a magical
+disturbance echoes through all magical-forest rooms. It would then be convenient to be able to
+simply find all rooms that are “magical” so you could send messages to them.
Zones try to separate rooms by global location. In our example we would separate the forest into
+two parts - the magical and the non-magical part. Each have a Meadow and rooms belonging to each
+part should be easy to retrieve.
+
Many MUD codebases hardcode zones as part of the engine and database. Evennia does no such
+distinction due to the fact that rooms themselves are meant to be customized to any level anyway.
+Below is a suggestion for how to implement zones in Evennia.
+
All objects in Evennia can hold any number of Tags. Tags are short labels that you attach to
+objects. They make it very easy to retrieve groups of objects. An object can have any number of
+different tags. So let’s attach the relevant tag to our forest:
You could add this manually, or automatically during creation somehow (you’d need to modify your
+@dig command for this, most likely). You can also use the default @tag command during building:
+
@tag forestobj = magicalforest : zone
+
+
+
Henceforth you can then easily retrieve only objects with a given tag:
The tagging or aliasing systems above don’t instill any sort of functional difference between a
+magical forest room and a normal one - they are just arbitrary ways to mark objects for quick
+retrieval later. Any functional differences must be expressed using Typeclasses.
+
Of course, an alternative way to implement zones themselves is to have all rooms/objects in a zone
+inherit from a given typeclass parent - and then limit your searches to objects inheriting from that
+given parent. The effect would be similar but you’d need to expand the search functionality to
+properly search the inheritance tree.
+"""
+Settings and configuration for Django.
+
+Read values from the module specified by the DJANGO_SETTINGS_MODULE environment
+variable, and then from django.conf.global_settings; see the global_settings.py
+for a list of all possible variables.
+"""
+
+importimportlib
+importos
+importtime
+importtraceback
+importwarnings
+frompathlibimportPath
+
+importdjango
+fromdjango.confimportglobal_settings
+fromdjango.core.exceptionsimportImproperlyConfigured
+fromdjango.utils.deprecationimportRemovedInDjango50Warning
+fromdjango.utils.functionalimportLazyObject,empty
+
+ENVIRONMENT_VARIABLE="DJANGO_SETTINGS_MODULE"
+
+# RemovedInDjango50Warning
+USE_DEPRECATED_PYTZ_DEPRECATED_MSG=(
+ 'The USE_DEPRECATED_PYTZ setting, and support for pytz timezones is '
+ 'deprecated in favor of the stdlib zoneinfo module. Please update your '
+ 'code to use zoneinfo and remove the USE_DEPRECATED_PYTZ setting.'
+)
+
+USE_L10N_DEPRECATED_MSG=(
+ 'The USE_L10N setting is deprecated. Starting with Django 5.0, localized '
+ 'formatting of data will always be enabled. For example Django will '
+ 'display numbers and dates using the format of the current locale.'
+)
+
+
+classSettingsReference(str):
+ """
+ String subclass which references a current settings value. It's treated as
+ the value in memory but serializes to a settings.NAME attribute reference.
+ """
+ def__new__(self,value,setting_name):
+ returnstr.__new__(self,value)
+
+ def__init__(self,value,setting_name):
+ self.setting_name=setting_name
+
+
+classLazySettings(LazyObject):
+ """
+ A lazy proxy for either global Django settings or a custom settings object.
+ The user can manually configure settings prior to using them. Otherwise,
+ Django uses the settings module pointed to by DJANGO_SETTINGS_MODULE.
+ """
+ def_setup(self,name=None):
+ """
+ Load the settings module pointed to by the environment variable. This
+ is used the first time settings are needed, if the user hasn't
+ configured settings manually.
+ """
+ settings_module=os.environ.get(ENVIRONMENT_VARIABLE)
+ ifnotsettings_module:
+ desc=("setting %s"%name)ifnameelse"settings"
+ raiseImproperlyConfigured(
+ "Requested %s, but settings are not configured. "
+ "You must either define the environment variable %s "
+ "or call settings.configure() before accessing settings."
+ %(desc,ENVIRONMENT_VARIABLE))
+
+ self._wrapped=Settings(settings_module)
+
+ def__repr__(self):
+ # Hardcode the class name as otherwise it yields 'Settings'.
+ ifself._wrappedisempty:
+ return'<LazySettings [Unevaluated]>'
+ return'<LazySettings "%(settings_module)s">'%{
+ 'settings_module':self._wrapped.SETTINGS_MODULE,
+ }
+
+ def__getattr__(self,name):
+ """Return the value of a setting and cache it in self.__dict__."""
+ ifself._wrappedisempty:
+ self._setup(name)
+ val=getattr(self._wrapped,name)
+
+ # Special case some settings which require further modification.
+ # This is done here for performance reasons so the modified value is cached.
+ ifnamein{'MEDIA_URL','STATIC_URL'}andvalisnotNone:
+ val=self._add_script_prefix(val)
+ elifname=='SECRET_KEY'andnotval:
+ raiseImproperlyConfigured("The SECRET_KEY setting must not be empty.")
+
+ self.__dict__[name]=val
+ returnval
+
+ def__setattr__(self,name,value):
+ """
+ Set the value of setting. Clear all cached values if _wrapped changes
+ (@override_settings does this) or clear single values when set.
+ """
+ ifname=='_wrapped':
+ self.__dict__.clear()
+ else:
+ self.__dict__.pop(name,None)
+ super().__setattr__(name,value)
+
+ def__delattr__(self,name):
+ """Delete a setting and clear it from cache if needed."""
+ super().__delattr__(name)
+ self.__dict__.pop(name,None)
+
+ defconfigure(self,default_settings=global_settings,**options):
+ """
+ Called to manually configure the settings. The 'default_settings'
+ parameter sets where to retrieve any unspecified values from (its
+ argument must support attribute access (__getattr__)).
+ """
+ ifself._wrappedisnotempty:
+ raiseRuntimeError('Settings already configured.')
+ holder=UserSettingsHolder(default_settings)
+ forname,valueinoptions.items():
+ ifnotname.isupper():
+ raiseTypeError('Setting %r must be uppercase.'%name)
+ setattr(holder,name,value)
+ self._wrapped=holder
+
+ @staticmethod
+ def_add_script_prefix(value):
+ """
+ Add SCRIPT_NAME prefix to relative paths.
+
+ Useful when the app is being served at a subpath and manually prefixing
+ subpath to STATIC_URL and MEDIA_URL in settings is inconvenient.
+ """
+ # Don't apply prefix to absolute paths and URLs.
+ ifvalue.startswith(('http://','https://','/')):
+ returnvalue
+ fromdjango.urlsimportget_script_prefix
+ return'%s%s'%(get_script_prefix(),value)
+
+ @property
+ defconfigured(self):
+ """Return True if the settings have already been configured."""
+ returnself._wrappedisnotempty
+
+ @property
+ defUSE_L10N(self):
+ stack=traceback.extract_stack()
+ # Show a warning if the setting is used outside of Django.
+ # Stack index: -1 this line, -2 the caller.
+ filename,_,_,_=stack[-2]
+ ifnotfilename.startswith(os.path.dirname(django.__file__)):
+ warnings.warn(
+ USE_L10N_DEPRECATED_MSG,
+ RemovedInDjango50Warning,
+ stacklevel=2,
+ )
+ returnself.__getattr__('USE_L10N')
+
+ # RemovedInDjango50Warning.
+ @property
+ def_USE_L10N_INTERNAL(self):
+ # Special hook to avoid checking a traceback in internal use on hot
+ # paths.
+ returnself.__getattr__('USE_L10N')
+
+
+classSettings:
+ def__init__(self,settings_module):
+ # update this dict from global settings (but only for ALL_CAPS settings)
+ forsettingindir(global_settings):
+ ifsetting.isupper():
+ setattr(self,setting,getattr(global_settings,setting))
+
+ # store the settings module in case someone later cares
+ self.SETTINGS_MODULE=settings_module
+
+ mod=importlib.import_module(self.SETTINGS_MODULE)
+
+ tuple_settings=(
+ 'ALLOWED_HOSTS',
+ "INSTALLED_APPS",
+ "TEMPLATE_DIRS",
+ "LOCALE_PATHS",
+ )
+ self._explicit_settings=set()
+ forsettingindir(mod):
+ ifsetting.isupper():
+ setting_value=getattr(mod,setting)
+
+ if(settingintuple_settingsand
+ notisinstance(setting_value,(list,tuple))):
+ raiseImproperlyConfigured("The %s setting must be a list or a tuple."%setting)
+ setattr(self,setting,setting_value)
+ self._explicit_settings.add(setting)
+
+ ifself.USE_TZisFalseandnotself.is_overridden('USE_TZ'):
+ warnings.warn(
+ 'The default value of USE_TZ will change from False to True '
+ 'in Django 5.0. Set USE_TZ to False in your project settings '
+ 'if you want to keep the current default behavior.',
+ category=RemovedInDjango50Warning,
+ )
+
+ ifself.is_overridden('USE_DEPRECATED_PYTZ'):
+ warnings.warn(USE_DEPRECATED_PYTZ_DEPRECATED_MSG,RemovedInDjango50Warning)
+
+ ifhasattr(time,'tzset')andself.TIME_ZONE:
+ # When we can, attempt to validate the timezone. If we can't find
+ # this file, no check happens and it's harmless.
+ zoneinfo_root=Path('/usr/share/zoneinfo')
+ zone_info_file=zoneinfo_root.joinpath(*self.TIME_ZONE.split('/'))
+ ifzoneinfo_root.exists()andnotzone_info_file.exists():
+ raiseValueError("Incorrect timezone setting: %s"%self.TIME_ZONE)
+ # Move the time zone info into os.environ. See ticket #2315 for why
+ # we don't do this unconditionally (breaks Windows).
+ os.environ['TZ']=self.TIME_ZONE
+ time.tzset()
+
+ ifself.is_overridden('USE_L10N'):
+ warnings.warn(USE_L10N_DEPRECATED_MSG,RemovedInDjango50Warning)
+
+ defis_overridden(self,setting):
+ returnsettinginself._explicit_settings
+
+ def__repr__(self):
+ return'<%(cls)s "%(settings_module)s">'%{
+ 'cls':self.__class__.__name__,
+ 'settings_module':self.SETTINGS_MODULE,
+ }
+
+
+classUserSettingsHolder:
+ """Holder for user configured settings."""
+ # SETTINGS_MODULE doesn't make much sense in the manually configured
+ # (standalone) case.
+ SETTINGS_MODULE=None
+
+ def__init__(self,default_settings):
+ """
+ Requests for configuration variables not in this class are satisfied
+ from the module specified in default_settings (if possible).
+ """
+ self.__dict__['_deleted']=set()
+ self.default_settings=default_settings
+
+ def__getattr__(self,name):
+ ifnotname.isupper()ornameinself._deleted:
+ raiseAttributeError
+ returngetattr(self.default_settings,name)
+
+ def__setattr__(self,name,value):
+ self._deleted.discard(name)
+ ifname=='USE_L10N':
+ warnings.warn(USE_L10N_DEPRECATED_MSG,RemovedInDjango50Warning)
+ super().__setattr__(name,value)
+ ifname=='USE_DEPRECATED_PYTZ':
+ warnings.warn(USE_DEPRECATED_PYTZ_DEPRECATED_MSG,RemovedInDjango50Warning)
+
+ def__delattr__(self,name):
+ self._deleted.add(name)
+ ifhasattr(self,name):
+ super().__delattr__(name)
+
+ def__dir__(self):
+ returnsorted(
+ sforsin[*self.__dict__,*dir(self.default_settings)]
+ ifsnotinself._deleted
+ )
+
+ defis_overridden(self,setting):
+ deleted=(settinginself._deleted)
+ set_locally=(settinginself.__dict__)
+ set_on_default=getattr(self.default_settings,'is_overridden',lambdas:False)(setting)
+ returndeletedorset_locallyorset_on_default
+
+ def__repr__(self):
+ return'<%(cls)s>'%{
+ 'cls':self.__class__.__name__,
+ }
+
+
+settings=LazySettings()
+
Source code for django.db.models.fields.related_descriptors
+"""
+Accessors for related objects.
+
+When a field defines a relation between two models, each model class provides
+an attribute to access related instances of the other model class (unless the
+reverse accessor has been disabled with related_name='+').
+
+Accessors are implemented as descriptors in order to customize access and
+assignment. This module defines the descriptor classes.
+
+Forward accessors follow foreign keys. Reverse accessors trace them back. For
+example, with the following models::
+
+ class Parent(Model):
+ pass
+
+ class Child(Model):
+ parent = ForeignKey(Parent, related_name='children')
+
+ ``child.parent`` is a forward many-to-one relation. ``parent.children`` is a
+reverse many-to-one relation.
+
+There are three types of relations (many-to-one, one-to-one, and many-to-many)
+and two directions (forward and reverse) for a total of six combinations.
+
+1. Related instance on the forward side of a many-to-one relation:
+ ``ForwardManyToOneDescriptor``.
+
+ Uniqueness of foreign key values is irrelevant to accessing the related
+ instance, making the many-to-one and one-to-one cases identical as far as
+ the descriptor is concerned. The constraint is checked upstream (unicity
+ validation in forms) or downstream (unique indexes in the database).
+
+2. Related instance on the forward side of a one-to-one
+ relation: ``ForwardOneToOneDescriptor``.
+
+ It avoids querying the database when accessing the parent link field in
+ a multi-table inheritance scenario.
+
+3. Related instance on the reverse side of a one-to-one relation:
+ ``ReverseOneToOneDescriptor``.
+
+ One-to-one relations are asymmetrical, despite the apparent symmetry of the
+ name, because they're implemented in the database with a foreign key from
+ one table to another. As a consequence ``ReverseOneToOneDescriptor`` is
+ slightly different from ``ForwardManyToOneDescriptor``.
+
+4. Related objects manager for related instances on the reverse side of a
+ many-to-one relation: ``ReverseManyToOneDescriptor``.
+
+ Unlike the previous two classes, this one provides access to a collection
+ of objects. It returns a manager rather than an instance.
+
+5. Related objects manager for related instances on the forward or reverse
+ sides of a many-to-many relation: ``ManyToManyDescriptor``.
+
+ Many-to-many relations are symmetrical. The syntax of Django models
+ requires declaring them on one side but that's an implementation detail.
+ They could be declared on the other side without any change in behavior.
+ Therefore the forward and reverse descriptors can be the same.
+
+ If you're looking for ``ForwardManyToManyDescriptor`` or
+ ``ReverseManyToManyDescriptor``, use ``ManyToManyDescriptor`` instead.
+"""
+
+fromdjango.core.exceptionsimportFieldError
+fromdjango.dbimportconnections,router,transaction
+fromdjango.db.modelsimportQ,signals
+fromdjango.db.models.queryimportQuerySet
+fromdjango.db.models.query_utilsimportDeferredAttribute
+fromdjango.db.models.utilsimportresolve_callables
+fromdjango.utils.functionalimportcached_property
+
+
+classForeignKeyDeferredAttribute(DeferredAttribute):
+ def__set__(self,instance,value):
+ ifinstance.__dict__.get(self.field.attname)!=valueandself.field.is_cached(instance):
+ self.field.delete_cached_value(instance)
+ instance.__dict__[self.field.attname]=value
+
+
+classForwardManyToOneDescriptor:
+ """
+ Accessor to the related object on the forward side of a many-to-one or
+ one-to-one (via ForwardOneToOneDescriptor subclass) relation.
+
+ In the example::
+
+ class Child(Model):
+ parent = ForeignKey(Parent, related_name='children')
+
+ ``Child.parent`` is a ``ForwardManyToOneDescriptor`` instance.
+ """
+
+ def__init__(self,field_with_rel):
+ self.field=field_with_rel
+
+ @cached_property
+ defRelatedObjectDoesNotExist(self):
+ # The exception can't be created at initialization time since the
+ # related model might not be resolved yet; `self.field.model` might
+ # still be a string model reference.
+ returntype(
+ 'RelatedObjectDoesNotExist',
+ (self.field.remote_field.model.DoesNotExist,AttributeError),{
+ '__module__':self.field.model.__module__,
+ '__qualname__':'%s.%s.RelatedObjectDoesNotExist'%(
+ self.field.model.__qualname__,
+ self.field.name,
+ ),
+ }
+ )
+
+ defis_cached(self,instance):
+ returnself.field.is_cached(instance)
+
+ defget_queryset(self,**hints):
+ returnself.field.remote_field.model._base_manager.db_manager(hints=hints).all()
+
+ defget_prefetch_queryset(self,instances,queryset=None):
+ ifquerysetisNone:
+ queryset=self.get_queryset()
+ queryset._add_hints(instance=instances[0])
+
+ rel_obj_attr=self.field.get_foreign_related_value
+ instance_attr=self.field.get_local_related_value
+ instances_dict={instance_attr(inst):instforinstininstances}
+ related_field=self.field.foreign_related_fields[0]
+ remote_field=self.field.remote_field
+
+ # FIXME: This will need to be revisited when we introduce support for
+ # composite fields. In the meantime we take this practical approach to
+ # solve a regression on 1.6 when the reverse manager in hidden
+ # (related_name ends with a '+'). Refs #21410.
+ # The check for len(...) == 1 is a special case that allows the query
+ # to be join-less and smaller. Refs #21760.
+ ifremote_field.is_hidden()orlen(self.field.foreign_related_fields)==1:
+ query={'%s__in'%related_field.name:{instance_attr(inst)[0]forinstininstances}}
+ else:
+ query={'%s__in'%self.field.related_query_name():instances}
+ queryset=queryset.filter(**query)
+
+ # Since we're going to assign directly in the cache,
+ # we must manage the reverse relation cache manually.
+ ifnotremote_field.multiple:
+ forrel_objinqueryset:
+ instance=instances_dict[rel_obj_attr(rel_obj)]
+ remote_field.set_cached_value(rel_obj,instance)
+ returnqueryset,rel_obj_attr,instance_attr,True,self.field.get_cache_name(),False
+
+ defget_object(self,instance):
+ qs=self.get_queryset(instance=instance)
+ # Assuming the database enforces foreign keys, this won't fail.
+ returnqs.get(self.field.get_reverse_related_filter(instance))
+
+ def__get__(self,instance,cls=None):
+ """
+ Get the related instance through the forward relation.
+
+ With the example above, when getting ``child.parent``:
+
+ - ``self`` is the descriptor managing the ``parent`` attribute
+ - ``instance`` is the ``child`` instance
+ - ``cls`` is the ``Child`` class (we don't need it)
+ """
+ ifinstanceisNone:
+ returnself
+
+ # The related instance is loaded from the database and then cached
+ # by the field on the model instance state. It can also be pre-cached
+ # by the reverse accessor (ReverseOneToOneDescriptor).
+ try:
+ rel_obj=self.field.get_cached_value(instance)
+ exceptKeyError:
+ has_value=Nonenotinself.field.get_local_related_value(instance)
+ ancestor_link=instance._meta.get_ancestor_link(self.field.model)ifhas_valueelseNone
+ ifancestor_linkandancestor_link.is_cached(instance):
+ # An ancestor link will exist if this field is defined on a
+ # multi-table inheritance parent of the instance's class.
+ ancestor=ancestor_link.get_cached_value(instance)
+ # The value might be cached on an ancestor if the instance
+ # originated from walking down the inheritance chain.
+ rel_obj=self.field.get_cached_value(ancestor,default=None)
+ else:
+ rel_obj=None
+ ifrel_objisNoneandhas_value:
+ rel_obj=self.get_object(instance)
+ remote_field=self.field.remote_field
+ # If this is a one-to-one relation, set the reverse accessor
+ # cache on the related object to the current instance to avoid
+ # an extra SQL query if it's accessed later on.
+ ifnotremote_field.multiple:
+ remote_field.set_cached_value(rel_obj,instance)
+ self.field.set_cached_value(instance,rel_obj)
+
+ ifrel_objisNoneandnotself.field.null:
+ raiseself.RelatedObjectDoesNotExist(
+ "%s has no %s."%(self.field.model.__name__,self.field.name)
+ )
+ else:
+ returnrel_obj
+
+ def__set__(self,instance,value):
+ """
+ Set the related instance through the forward relation.
+
+ With the example above, when setting ``child.parent = parent``:
+
+ - ``self`` is the descriptor managing the ``parent`` attribute
+ - ``instance`` is the ``child`` instance
+ - ``value`` is the ``parent`` instance on the right of the equal sign
+ """
+ # An object must be an instance of the related class.
+ ifvalueisnotNoneandnotisinstance(value,self.field.remote_field.model._meta.concrete_model):
+ raiseValueError(
+ 'Cannot assign "%r": "%s.%s" must be a "%s" instance.'%(
+ value,
+ instance._meta.object_name,
+ self.field.name,
+ self.field.remote_field.model._meta.object_name,
+ )
+ )
+ elifvalueisnotNone:
+ ifinstance._state.dbisNone:
+ instance._state.db=router.db_for_write(instance.__class__,instance=value)
+ ifvalue._state.dbisNone:
+ value._state.db=router.db_for_write(value.__class__,instance=instance)
+ ifnotrouter.allow_relation(value,instance):
+ raiseValueError('Cannot assign "%r": the current database router prevents this relation.'%value)
+
+ remote_field=self.field.remote_field
+ # If we're setting the value of a OneToOneField to None, we need to clear
+ # out the cache on any old related object. Otherwise, deleting the
+ # previously-related object will also cause this object to be deleted,
+ # which is wrong.
+ ifvalueisNone:
+ # Look up the previously-related object, which may still be available
+ # since we've not yet cleared out the related field.
+ # Use the cache directly, instead of the accessor; if we haven't
+ # populated the cache, then we don't care - we're only accessing
+ # the object to invalidate the accessor cache, so there's no
+ # need to populate the cache just to expire it again.
+ related=self.field.get_cached_value(instance,default=None)
+
+ # If we've got an old related object, we need to clear out its
+ # cache. This cache also might not exist if the related object
+ # hasn't been accessed yet.
+ ifrelatedisnotNone:
+ remote_field.set_cached_value(related,None)
+
+ forlh_field,rh_fieldinself.field.related_fields:
+ setattr(instance,lh_field.attname,None)
+
+ # Set the values of the related field.
+ else:
+ forlh_field,rh_fieldinself.field.related_fields:
+ setattr(instance,lh_field.attname,getattr(value,rh_field.attname))
+
+ # Set the related instance cache used by __get__ to avoid an SQL query
+ # when accessing the attribute we just set.
+ self.field.set_cached_value(instance,value)
+
+ # If this is a one-to-one relation, set the reverse accessor cache on
+ # the related object to the current instance to avoid an extra SQL
+ # query if it's accessed later on.
+ ifvalueisnotNoneandnotremote_field.multiple:
+ remote_field.set_cached_value(value,instance)
+
+ def__reduce__(self):
+ """
+ Pickling should return the instance attached by self.field on the
+ model, not a new copy of that descriptor. Use getattr() to retrieve
+ the instance directly from the model.
+ """
+ returngetattr,(self.field.model,self.field.name)
+
+
+classForwardOneToOneDescriptor(ForwardManyToOneDescriptor):
+ """
+ Accessor to the related object on the forward side of a one-to-one relation.
+
+ In the example::
+
+ class Restaurant(Model):
+ place = OneToOneField(Place, related_name='restaurant')
+
+ ``Restaurant.place`` is a ``ForwardOneToOneDescriptor`` instance.
+ """
+
+ defget_object(self,instance):
+ ifself.field.remote_field.parent_link:
+ deferred=instance.get_deferred_fields()
+ # Because it's a parent link, all the data is available in the
+ # instance, so populate the parent model with this data.
+ rel_model=self.field.remote_field.model
+ fields=[field.attnameforfieldinrel_model._meta.concrete_fields]
+
+ # If any of the related model's fields are deferred, fallback to
+ # fetching all fields from the related model. This avoids a query
+ # on the related model for every deferred field.
+ ifnotany(fieldinfieldsforfieldindeferred):
+ kwargs={field:getattr(instance,field)forfieldinfields}
+ obj=rel_model(**kwargs)
+ obj._state.adding=instance._state.adding
+ obj._state.db=instance._state.db
+ returnobj
+ returnsuper().get_object(instance)
+
+ def__set__(self,instance,value):
+ super().__set__(instance,value)
+ # If the primary key is a link to a parent model and a parent instance
+ # is being set, update the value of the inherited pk(s).
+ ifself.field.primary_keyandself.field.remote_field.parent_link:
+ opts=instance._meta
+ # Inherited primary key fields from this object's base classes.
+ inherited_pk_fields=[
+ fieldforfieldinopts.concrete_fields
+ iffield.primary_keyandfield.remote_field
+ ]
+ forfieldininherited_pk_fields:
+ rel_model_pk_name=field.remote_field.model._meta.pk.attname
+ raw_value=getattr(value,rel_model_pk_name)ifvalueisnotNoneelseNone
+ setattr(instance,rel_model_pk_name,raw_value)
+
+
+classReverseOneToOneDescriptor:
+ """
+ Accessor to the related object on the reverse side of a one-to-one
+ relation.
+
+ In the example::
+
+ class Restaurant(Model):
+ place = OneToOneField(Place, related_name='restaurant')
+
+ ``Place.restaurant`` is a ``ReverseOneToOneDescriptor`` instance.
+ """
+
+ def__init__(self,related):
+ # Following the example above, `related` is an instance of OneToOneRel
+ # which represents the reverse restaurant field (place.restaurant).
+ self.related=related
+
+ @cached_property
+ defRelatedObjectDoesNotExist(self):
+ # The exception isn't created at initialization time for the sake of
+ # consistency with `ForwardManyToOneDescriptor`.
+ returntype(
+ 'RelatedObjectDoesNotExist',
+ (self.related.related_model.DoesNotExist,AttributeError),{
+ '__module__':self.related.model.__module__,
+ '__qualname__':'%s.%s.RelatedObjectDoesNotExist'%(
+ self.related.model.__qualname__,
+ self.related.name,
+ )
+ },
+ )
+
+ defis_cached(self,instance):
+ returnself.related.is_cached(instance)
+
+ defget_queryset(self,**hints):
+ returnself.related.related_model._base_manager.db_manager(hints=hints).all()
+
+ defget_prefetch_queryset(self,instances,queryset=None):
+ ifquerysetisNone:
+ queryset=self.get_queryset()
+ queryset._add_hints(instance=instances[0])
+
+ rel_obj_attr=self.related.field.get_local_related_value
+ instance_attr=self.related.field.get_foreign_related_value
+ instances_dict={instance_attr(inst):instforinstininstances}
+ query={'%s__in'%self.related.field.name:instances}
+ queryset=queryset.filter(**query)
+
+ # Since we're going to assign directly in the cache,
+ # we must manage the reverse relation cache manually.
+ forrel_objinqueryset:
+ instance=instances_dict[rel_obj_attr(rel_obj)]
+ self.related.field.set_cached_value(rel_obj,instance)
+ returnqueryset,rel_obj_attr,instance_attr,True,self.related.get_cache_name(),False
+
+ def__get__(self,instance,cls=None):
+ """
+ Get the related instance through the reverse relation.
+
+ With the example above, when getting ``place.restaurant``:
+
+ - ``self`` is the descriptor managing the ``restaurant`` attribute
+ - ``instance`` is the ``place`` instance
+ - ``cls`` is the ``Place`` class (unused)
+
+ Keep in mind that ``Restaurant`` holds the foreign key to ``Place``.
+ """
+ ifinstanceisNone:
+ returnself
+
+ # The related instance is loaded from the database and then cached
+ # by the field on the model instance state. It can also be pre-cached
+ # by the forward accessor (ForwardManyToOneDescriptor).
+ try:
+ rel_obj=self.related.get_cached_value(instance)
+ exceptKeyError:
+ related_pk=instance.pk
+ ifrelated_pkisNone:
+ rel_obj=None
+ else:
+ filter_args=self.related.field.get_forward_related_filter(instance)
+ try:
+ rel_obj=self.get_queryset(instance=instance).get(**filter_args)
+ exceptself.related.related_model.DoesNotExist:
+ rel_obj=None
+ else:
+ # Set the forward accessor cache on the related object to
+ # the current instance to avoid an extra SQL query if it's
+ # accessed later on.
+ self.related.field.set_cached_value(rel_obj,instance)
+ self.related.set_cached_value(instance,rel_obj)
+
+ ifrel_objisNone:
+ raiseself.RelatedObjectDoesNotExist(
+ "%s has no %s."%(
+ instance.__class__.__name__,
+ self.related.get_accessor_name()
+ )
+ )
+ else:
+ returnrel_obj
+
+ def__set__(self,instance,value):
+ """
+ Set the related instance through the reverse relation.
+
+ With the example above, when setting ``place.restaurant = restaurant``:
+
+ - ``self`` is the descriptor managing the ``restaurant`` attribute
+ - ``instance`` is the ``place`` instance
+ - ``value`` is the ``restaurant`` instance on the right of the equal sign
+
+ Keep in mind that ``Restaurant`` holds the foreign key to ``Place``.
+ """
+ # The similarity of the code below to the code in
+ # ForwardManyToOneDescriptor is annoying, but there's a bunch
+ # of small differences that would make a common base class convoluted.
+
+ ifvalueisNone:
+ # Update the cached related instance (if any) & clear the cache.
+ # Following the example above, this would be the cached
+ # ``restaurant`` instance (if any).
+ rel_obj=self.related.get_cached_value(instance,default=None)
+ ifrel_objisnotNone:
+ # Remove the ``restaurant`` instance from the ``place``
+ # instance cache.
+ self.related.delete_cached_value(instance)
+ # Set the ``place`` field on the ``restaurant``
+ # instance to None.
+ setattr(rel_obj,self.related.field.name,None)
+ elifnotisinstance(value,self.related.related_model):
+ # An object must be an instance of the related class.
+ raiseValueError(
+ 'Cannot assign "%r": "%s.%s" must be a "%s" instance.'%(
+ value,
+ instance._meta.object_name,
+ self.related.get_accessor_name(),
+ self.related.related_model._meta.object_name,
+ )
+ )
+ else:
+ ifinstance._state.dbisNone:
+ instance._state.db=router.db_for_write(instance.__class__,instance=value)
+ ifvalue._state.dbisNone:
+ value._state.db=router.db_for_write(value.__class__,instance=instance)
+ ifnotrouter.allow_relation(value,instance):
+ raiseValueError('Cannot assign "%r": the current database router prevents this relation.'%value)
+
+ related_pk=tuple(getattr(instance,field.attname)forfieldinself.related.field.foreign_related_fields)
+ # Set the value of the related field to the value of the related object's related field
+ forindex,fieldinenumerate(self.related.field.local_related_fields):
+ setattr(value,field.attname,related_pk[index])
+
+ # Set the related instance cache used by __get__ to avoid an SQL query
+ # when accessing the attribute we just set.
+ self.related.set_cached_value(instance,value)
+
+ # Set the forward accessor cache on the related object to the current
+ # instance to avoid an extra SQL query if it's accessed later on.
+ self.related.field.set_cached_value(value,instance)
+
+ def__reduce__(self):
+ # Same purpose as ForwardManyToOneDescriptor.__reduce__().
+ returngetattr,(self.related.model,self.related.name)
+
+
+classReverseManyToOneDescriptor:
+ """
+ Accessor to the related objects manager on the reverse side of a
+ many-to-one relation.
+
+ In the example::
+
+ class Child(Model):
+ parent = ForeignKey(Parent, related_name='children')
+
+ ``Parent.children`` is a ``ReverseManyToOneDescriptor`` instance.
+
+ Most of the implementation is delegated to a dynamically defined manager
+ class built by ``create_forward_many_to_many_manager()`` defined below.
+ """
+
+ def__init__(self,rel):
+ self.rel=rel
+ self.field=rel.field
+
+ @cached_property
+ defrelated_manager_cls(self):
+ related_model=self.rel.related_model
+
+ returncreate_reverse_many_to_one_manager(
+ related_model._default_manager.__class__,
+ self.rel,
+ )
+
+ def__get__(self,instance,cls=None):
+ """
+ Get the related objects through the reverse relation.
+
+ With the example above, when getting ``parent.children``:
+
+ - ``self`` is the descriptor managing the ``children`` attribute
+ - ``instance`` is the ``parent`` instance
+ - ``cls`` is the ``Parent`` class (unused)
+ """
+ ifinstanceisNone:
+ returnself
+
+ returnself.related_manager_cls(instance)
+
+ def_get_set_deprecation_msg_params(self):
+ return(
+ 'reverse side of a related set',
+ self.rel.get_accessor_name(),
+ )
+
+ def__set__(self,instance,value):
+ raiseTypeError(
+ 'Direct assignment to the %s is prohibited. Use %s.set() instead.'
+ %self._get_set_deprecation_msg_params(),
+ )
+
+
+defcreate_reverse_many_to_one_manager(superclass,rel):
+ """
+ Create a manager for the reverse side of a many-to-one relation.
+
+ This manager subclasses another manager, generally the default manager of
+ the related model, and adds behaviors specific to many-to-one relations.
+ """
+
+ classRelatedManager(superclass):
+ def__init__(self,instance):
+ super().__init__()
+
+ self.instance=instance
+ self.model=rel.related_model
+ self.field=rel.field
+
+ self.core_filters={self.field.name:instance}
+
+ def__call__(self,*,manager):
+ manager=getattr(self.model,manager)
+ manager_class=create_reverse_many_to_one_manager(manager.__class__,rel)
+ returnmanager_class(self.instance)
+ do_not_call_in_templates=True
+
+ def_apply_rel_filters(self,queryset):
+ """
+ Filter the queryset for the instance this manager is bound to.
+ """
+ db=self._dborrouter.db_for_read(self.model,instance=self.instance)
+ empty_strings_as_null=connections[db].features.interprets_empty_strings_as_nulls
+ queryset._add_hints(instance=self.instance)
+ ifself._db:
+ queryset=queryset.using(self._db)
+ queryset._defer_next_filter=True
+ queryset=queryset.filter(**self.core_filters)
+ forfieldinself.field.foreign_related_fields:
+ val=getattr(self.instance,field.attname)
+ ifvalisNoneor(val==''andempty_strings_as_null):
+ returnqueryset.none()
+ ifself.field.many_to_one:
+ # Guard against field-like objects such as GenericRelation
+ # that abuse create_reverse_many_to_one_manager() with reverse
+ # one-to-many relationships instead and break known related
+ # objects assignment.
+ try:
+ target_field=self.field.target_field
+ exceptFieldError:
+ # The relationship has multiple target fields. Use a tuple
+ # for related object id.
+ rel_obj_id=tuple([
+ getattr(self.instance,target_field.attname)
+ fortarget_fieldinself.field.get_path_info()[-1].target_fields
+ ])
+ else:
+ rel_obj_id=getattr(self.instance,target_field.attname)
+ queryset._known_related_objects={self.field:{rel_obj_id:self.instance}}
+ returnqueryset
+
+ def_remove_prefetched_objects(self):
+ try:
+ self.instance._prefetched_objects_cache.pop(self.field.remote_field.get_cache_name())
+ except(AttributeError,KeyError):
+ pass# nothing to clear from cache
+
+ defget_queryset(self):
+ try:
+ returnself.instance._prefetched_objects_cache[self.field.remote_field.get_cache_name()]
+ except(AttributeError,KeyError):
+ queryset=super().get_queryset()
+ returnself._apply_rel_filters(queryset)
+
+ defget_prefetch_queryset(self,instances,queryset=None):
+ ifquerysetisNone:
+ queryset=super().get_queryset()
+
+ queryset._add_hints(instance=instances[0])
+ queryset=queryset.using(queryset._dborself._db)
+
+ rel_obj_attr=self.field.get_local_related_value
+ instance_attr=self.field.get_foreign_related_value
+ instances_dict={instance_attr(inst):instforinstininstances}
+ query={'%s__in'%self.field.name:instances}
+ queryset=queryset.filter(**query)
+
+ # Since we just bypassed this class' get_queryset(), we must manage
+ # the reverse relation manually.
+ forrel_objinqueryset:
+ instance=instances_dict[rel_obj_attr(rel_obj)]
+ setattr(rel_obj,self.field.name,instance)
+ cache_name=self.field.remote_field.get_cache_name()
+ returnqueryset,rel_obj_attr,instance_attr,False,cache_name,False
+
+ defadd(self,*objs,bulk=True):
+ self._remove_prefetched_objects()
+ db=router.db_for_write(self.model,instance=self.instance)
+
+ defcheck_and_update_obj(obj):
+ ifnotisinstance(obj,self.model):
+ raiseTypeError("'%s' instance expected, got %r"%(
+ self.model._meta.object_name,obj,
+ ))
+ setattr(obj,self.field.name,self.instance)
+
+ ifbulk:
+ pks=[]
+ forobjinobjs:
+ check_and_update_obj(obj)
+ ifobj._state.addingorobj._state.db!=db:
+ raiseValueError(
+ "%r instance isn't saved. Use bulk=False or save "
+ "the object first."%obj
+ )
+ pks.append(obj.pk)
+ self.model._base_manager.using(db).filter(pk__in=pks).update(**{
+ self.field.name:self.instance,
+ })
+ else:
+ withtransaction.atomic(using=db,savepoint=False):
+ forobjinobjs:
+ check_and_update_obj(obj)
+ obj.save()
+ add.alters_data=True
+
+ defcreate(self,**kwargs):
+ kwargs[self.field.name]=self.instance
+ db=router.db_for_write(self.model,instance=self.instance)
+ returnsuper(RelatedManager,self.db_manager(db)).create(**kwargs)
+ create.alters_data=True
+
+ defget_or_create(self,**kwargs):
+ kwargs[self.field.name]=self.instance
+ db=router.db_for_write(self.model,instance=self.instance)
+ returnsuper(RelatedManager,self.db_manager(db)).get_or_create(**kwargs)
+ get_or_create.alters_data=True
+
+ defupdate_or_create(self,**kwargs):
+ kwargs[self.field.name]=self.instance
+ db=router.db_for_write(self.model,instance=self.instance)
+ returnsuper(RelatedManager,self.db_manager(db)).update_or_create(**kwargs)
+ update_or_create.alters_data=True
+
+ # remove() and clear() are only provided if the ForeignKey can have a value of null.
+ ifrel.field.null:
+ defremove(self,*objs,bulk=True):
+ ifnotobjs:
+ return
+ val=self.field.get_foreign_related_value(self.instance)
+ old_ids=set()
+ forobjinobjs:
+ ifnotisinstance(obj,self.model):
+ raiseTypeError("'%s' instance expected, got %r"%(
+ self.model._meta.object_name,obj,
+ ))
+ # Is obj actually part of this descriptor set?
+ ifself.field.get_local_related_value(obj)==val:
+ old_ids.add(obj.pk)
+ else:
+ raiseself.field.remote_field.model.DoesNotExist(
+ "%r is not related to %r."%(obj,self.instance)
+ )
+ self._clear(self.filter(pk__in=old_ids),bulk)
+ remove.alters_data=True
+
+ defclear(self,*,bulk=True):
+ self._clear(self,bulk)
+ clear.alters_data=True
+
+ def_clear(self,queryset,bulk):
+ self._remove_prefetched_objects()
+ db=router.db_for_write(self.model,instance=self.instance)
+ queryset=queryset.using(db)
+ ifbulk:
+ # `QuerySet.update()` is intrinsically atomic.
+ queryset.update(**{self.field.name:None})
+ else:
+ withtransaction.atomic(using=db,savepoint=False):
+ forobjinqueryset:
+ setattr(obj,self.field.name,None)
+ obj.save(update_fields=[self.field.name])
+ _clear.alters_data=True
+
+ defset(self,objs,*,bulk=True,clear=False):
+ # Force evaluation of `objs` in case it's a queryset whose value
+ # could be affected by `manager.clear()`. Refs #19816.
+ objs=tuple(objs)
+
+ ifself.field.null:
+ db=router.db_for_write(self.model,instance=self.instance)
+ withtransaction.atomic(using=db,savepoint=False):
+ ifclear:
+ self.clear(bulk=bulk)
+ self.add(*objs,bulk=bulk)
+ else:
+ old_objs=set(self.using(db).all())
+ new_objs=[]
+ forobjinobjs:
+ ifobjinold_objs:
+ old_objs.remove(obj)
+ else:
+ new_objs.append(obj)
+
+ self.remove(*old_objs,bulk=bulk)
+ self.add(*new_objs,bulk=bulk)
+ else:
+ self.add(*objs,bulk=bulk)
+ set.alters_data=True
+
+ returnRelatedManager
+
+
+classManyToManyDescriptor(ReverseManyToOneDescriptor):
+ """
+ Accessor to the related objects manager on the forward and reverse sides of
+ a many-to-many relation.
+
+ In the example::
+
+ class Pizza(Model):
+ toppings = ManyToManyField(Topping, related_name='pizzas')
+
+ ``Pizza.toppings`` and ``Topping.pizzas`` are ``ManyToManyDescriptor``
+ instances.
+
+ Most of the implementation is delegated to a dynamically defined manager
+ class built by ``create_forward_many_to_many_manager()`` defined below.
+ """
+
+ def__init__(self,rel,reverse=False):
+ super().__init__(rel)
+
+ self.reverse=reverse
+
+ @property
+ defthrough(self):
+ # through is provided so that you have easy access to the through
+ # model (Book.authors.through) for inlines, etc. This is done as
+ # a property to ensure that the fully resolved value is returned.
+ returnself.rel.through
+
+ @cached_property
+ defrelated_manager_cls(self):
+ related_model=self.rel.related_modelifself.reverseelseself.rel.model
+
+ returncreate_forward_many_to_many_manager(
+ related_model._default_manager.__class__,
+ self.rel,
+ reverse=self.reverse,
+ )
+
+ def_get_set_deprecation_msg_params(self):
+ return(
+ '%s side of a many-to-many set'%('reverse'ifself.reverseelse'forward'),
+ self.rel.get_accessor_name()ifself.reverseelseself.field.name,
+ )
+
+
+defcreate_forward_many_to_many_manager(superclass,rel,reverse):
+ """
+ Create a manager for the either side of a many-to-many relation.
+
+ This manager subclasses another manager, generally the default manager of
+ the related model, and adds behaviors specific to many-to-many relations.
+ """
+
+ classManyRelatedManager(superclass):
+ def__init__(self,instance=None):
+ super().__init__()
+
+ self.instance=instance
+
+ ifnotreverse:
+ self.model=rel.model
+ self.query_field_name=rel.field.related_query_name()
+ self.prefetch_cache_name=rel.field.name
+ self.source_field_name=rel.field.m2m_field_name()
+ self.target_field_name=rel.field.m2m_reverse_field_name()
+ self.symmetrical=rel.symmetrical
+ else:
+ self.model=rel.related_model
+ self.query_field_name=rel.field.name
+ self.prefetch_cache_name=rel.field.related_query_name()
+ self.source_field_name=rel.field.m2m_reverse_field_name()
+ self.target_field_name=rel.field.m2m_field_name()
+ self.symmetrical=False
+
+ self.through=rel.through
+ self.reverse=reverse
+
+ self.source_field=self.through._meta.get_field(self.source_field_name)
+ self.target_field=self.through._meta.get_field(self.target_field_name)
+
+ self.core_filters={}
+ self.pk_field_names={}
+ forlh_field,rh_fieldinself.source_field.related_fields:
+ core_filter_key='%s__%s'%(self.query_field_name,rh_field.name)
+ self.core_filters[core_filter_key]=getattr(instance,rh_field.attname)
+ self.pk_field_names[lh_field.name]=rh_field.name
+
+ self.related_val=self.source_field.get_foreign_related_value(instance)
+ ifNoneinself.related_val:
+ raiseValueError('"%r" needs to have a value for field "%s" before '
+ 'this many-to-many relationship can be used.'%
+ (instance,self.pk_field_names[self.source_field_name]))
+ # Even if this relation is not to pk, we require still pk value.
+ # The wish is that the instance has been already saved to DB,
+ # although having a pk value isn't a guarantee of that.
+ ifinstance.pkisNone:
+ raiseValueError("%r instance needs to have a primary key value before "
+ "a many-to-many relationship can be used."%
+ instance.__class__.__name__)
+
+ def__call__(self,*,manager):
+ manager=getattr(self.model,manager)
+ manager_class=create_forward_many_to_many_manager(manager.__class__,rel,reverse)
+ returnmanager_class(instance=self.instance)
+ do_not_call_in_templates=True
+
+ def_build_remove_filters(self,removed_vals):
+ filters=Q((self.source_field_name,self.related_val))
+ # No need to add a subquery condition if removed_vals is a QuerySet without
+ # filters.
+ removed_vals_filters=(notisinstance(removed_vals,QuerySet)or
+ removed_vals._has_filters())
+ ifremoved_vals_filters:
+ filters&=Q((f'{self.target_field_name}__in',removed_vals))
+ ifself.symmetrical:
+ symmetrical_filters=Q((self.target_field_name,self.related_val))
+ ifremoved_vals_filters:
+ symmetrical_filters&=Q((f'{self.source_field_name}__in',removed_vals))
+ filters|=symmetrical_filters
+ returnfilters
+
+ def_apply_rel_filters(self,queryset):
+ """
+ Filter the queryset for the instance this manager is bound to.
+ """
+ queryset._add_hints(instance=self.instance)
+ ifself._db:
+ queryset=queryset.using(self._db)
+ queryset._defer_next_filter=True
+ returnqueryset._next_is_sticky().filter(**self.core_filters)
+
+ def_remove_prefetched_objects(self):
+ try:
+ self.instance._prefetched_objects_cache.pop(self.prefetch_cache_name)
+ except(AttributeError,KeyError):
+ pass# nothing to clear from cache
+
+ defget_queryset(self):
+ try:
+ returnself.instance._prefetched_objects_cache[self.prefetch_cache_name]
+ except(AttributeError,KeyError):
+ queryset=super().get_queryset()
+ returnself._apply_rel_filters(queryset)
+
+ defget_prefetch_queryset(self,instances,queryset=None):
+ ifquerysetisNone:
+ queryset=super().get_queryset()
+
+ queryset._add_hints(instance=instances[0])
+ queryset=queryset.using(queryset._dborself._db)
+
+ query={'%s__in'%self.query_field_name:instances}
+ queryset=queryset._next_is_sticky().filter(**query)
+
+ # M2M: need to annotate the query in order to get the primary model
+ # that the secondary model was actually related to. We know that
+ # there will already be a join on the join table, so we can just add
+ # the select.
+
+ # For non-autocreated 'through' models, can't assume we are
+ # dealing with PK values.
+ fk=self.through._meta.get_field(self.source_field_name)
+ join_table=fk.model._meta.db_table
+ connection=connections[queryset.db]
+ qn=connection.ops.quote_name
+ queryset=queryset.extra(select={
+ '_prefetch_related_val_%s'%f.attname:
+ '%s.%s'%(qn(join_table),qn(f.column))forfinfk.local_related_fields})
+ return(
+ queryset,
+ lambdaresult:tuple(
+ getattr(result,'_prefetch_related_val_%s'%f.attname)
+ forfinfk.local_related_fields
+ ),
+ lambdainst:tuple(
+ f.get_db_prep_value(getattr(inst,f.attname),connection)
+ forfinfk.foreign_related_fields
+ ),
+ False,
+ self.prefetch_cache_name,
+ False,
+ )
+
+ defadd(self,*objs,through_defaults=None):
+ self._remove_prefetched_objects()
+ db=router.db_for_write(self.through,instance=self.instance)
+ withtransaction.atomic(using=db,savepoint=False):
+ self._add_items(
+ self.source_field_name,self.target_field_name,*objs,
+ through_defaults=through_defaults,
+ )
+ # If this is a symmetrical m2m relation to self, add the mirror
+ # entry in the m2m table.
+ ifself.symmetrical:
+ self._add_items(
+ self.target_field_name,
+ self.source_field_name,
+ *objs,
+ through_defaults=through_defaults,
+ )
+ add.alters_data=True
+
+ defremove(self,*objs):
+ self._remove_prefetched_objects()
+ self._remove_items(self.source_field_name,self.target_field_name,*objs)
+ remove.alters_data=True
+
+ defclear(self):
+ db=router.db_for_write(self.through,instance=self.instance)
+ withtransaction.atomic(using=db,savepoint=False):
+ signals.m2m_changed.send(
+ sender=self.through,action="pre_clear",
+ instance=self.instance,reverse=self.reverse,
+ model=self.model,pk_set=None,using=db,
+ )
+ self._remove_prefetched_objects()
+ filters=self._build_remove_filters(super().get_queryset().using(db))
+ self.through._default_manager.using(db).filter(filters).delete()
+
+ signals.m2m_changed.send(
+ sender=self.through,action="post_clear",
+ instance=self.instance,reverse=self.reverse,
+ model=self.model,pk_set=None,using=db,
+ )
+ clear.alters_data=True
+
+ defset(self,objs,*,clear=False,through_defaults=None):
+ # Force evaluation of `objs` in case it's a queryset whose value
+ # could be affected by `manager.clear()`. Refs #19816.
+ objs=tuple(objs)
+
+ db=router.db_for_write(self.through,instance=self.instance)
+ withtransaction.atomic(using=db,savepoint=False):
+ ifclear:
+ self.clear()
+ self.add(*objs,through_defaults=through_defaults)
+ else:
+ old_ids=set(self.using(db).values_list(self.target_field.target_field.attname,flat=True))
+
+ new_objs=[]
+ forobjinobjs:
+ fk_val=(
+ self.target_field.get_foreign_related_value(obj)[0]
+ ifisinstance(obj,self.model)
+ elseself.target_field.get_prep_value(obj)
+ )
+ iffk_valinold_ids:
+ old_ids.remove(fk_val)
+ else:
+ new_objs.append(obj)
+
+ self.remove(*old_ids)
+ self.add(*new_objs,through_defaults=through_defaults)
+ set.alters_data=True
+
+ defcreate(self,*,through_defaults=None,**kwargs):
+ db=router.db_for_write(self.instance.__class__,instance=self.instance)
+ new_obj=super(ManyRelatedManager,self.db_manager(db)).create(**kwargs)
+ self.add(new_obj,through_defaults=through_defaults)
+ returnnew_obj
+ create.alters_data=True
+
+ defget_or_create(self,*,through_defaults=None,**kwargs):
+ db=router.db_for_write(self.instance.__class__,instance=self.instance)
+ obj,created=super(ManyRelatedManager,self.db_manager(db)).get_or_create(**kwargs)
+ # We only need to add() if created because if we got an object back
+ # from get() then the relationship already exists.
+ ifcreated:
+ self.add(obj,through_defaults=through_defaults)
+ returnobj,created
+ get_or_create.alters_data=True
+
+ defupdate_or_create(self,*,through_defaults=None,**kwargs):
+ db=router.db_for_write(self.instance.__class__,instance=self.instance)
+ obj,created=super(ManyRelatedManager,self.db_manager(db)).update_or_create(**kwargs)
+ # We only need to add() if created because if we got an object back
+ # from get() then the relationship already exists.
+ ifcreated:
+ self.add(obj,through_defaults=through_defaults)
+ returnobj,created
+ update_or_create.alters_data=True
+
+ def_get_target_ids(self,target_field_name,objs):
+ """
+ Return the set of ids of `objs` that the target field references.
+ """
+ fromdjango.db.modelsimportModel
+ target_ids=set()
+ target_field=self.through._meta.get_field(target_field_name)
+ forobjinobjs:
+ ifisinstance(obj,self.model):
+ ifnotrouter.allow_relation(obj,self.instance):
+ raiseValueError(
+ 'Cannot add "%r": instance is on database "%s", '
+ 'value is on database "%s"'%
+ (obj,self.instance._state.db,obj._state.db)
+ )
+ target_id=target_field.get_foreign_related_value(obj)[0]
+ iftarget_idisNone:
+ raiseValueError(
+ 'Cannot add "%r": the value for field "%s" is None'%
+ (obj,target_field_name)
+ )
+ target_ids.add(target_id)
+ elifisinstance(obj,Model):
+ raiseTypeError(
+ "'%s' instance expected, got %r"%
+ (self.model._meta.object_name,obj)
+ )
+ else:
+ target_ids.add(target_field.get_prep_value(obj))
+ returntarget_ids
+
+ def_get_missing_target_ids(self,source_field_name,target_field_name,db,target_ids):
+ """
+ Return the subset of ids of `objs` that aren't already assigned to
+ this relationship.
+ """
+ vals=self.through._default_manager.using(db).values_list(
+ target_field_name,flat=True
+ ).filter(**{
+ source_field_name:self.related_val[0],
+ '%s__in'%target_field_name:target_ids,
+ })
+ returntarget_ids.difference(vals)
+
+ def_get_add_plan(self,db,source_field_name):
+ """
+ Return a boolean triple of the way the add should be performed.
+
+ The first element is whether or not bulk_create(ignore_conflicts)
+ can be used, the second whether or not signals must be sent, and
+ the third element is whether or not the immediate bulk insertion
+ with conflicts ignored can be performed.
+ """
+ # Conflicts can be ignored when the intermediary model is
+ # auto-created as the only possible collision is on the
+ # (source_id, target_id) tuple. The same assertion doesn't hold for
+ # user-defined intermediary models as they could have other fields
+ # causing conflicts which must be surfaced.
+ can_ignore_conflicts=(
+ connections[db].features.supports_ignore_conflictsand
+ self.through._meta.auto_createdisnotFalse
+ )
+ # Don't send the signal when inserting duplicate data row
+ # for symmetrical reverse entries.
+ must_send_signals=(self.reverseorsource_field_name==self.source_field_name)and(
+ signals.m2m_changed.has_listeners(self.through)
+ )
+ # Fast addition through bulk insertion can only be performed
+ # if no m2m_changed listeners are connected for self.through
+ # as they require the added set of ids to be provided via
+ # pk_set.
+ returncan_ignore_conflicts,must_send_signals,(can_ignore_conflictsandnotmust_send_signals)
+
+ def_add_items(self,source_field_name,target_field_name,*objs,through_defaults=None):
+ # source_field_name: the PK fieldname in join table for the source object
+ # target_field_name: the PK fieldname in join table for the target object
+ # *objs - objects to add. Either object instances, or primary keys of object instances.
+ ifnotobjs:
+ return
+
+ through_defaults=dict(resolve_callables(through_defaultsor{}))
+ target_ids=self._get_target_ids(target_field_name,objs)
+ db=router.db_for_write(self.through,instance=self.instance)
+ can_ignore_conflicts,must_send_signals,can_fast_add=self._get_add_plan(db,source_field_name)
+ ifcan_fast_add:
+ self.through._default_manager.using(db).bulk_create([
+ self.through(**{
+ '%s_id'%source_field_name:self.related_val[0],
+ '%s_id'%target_field_name:target_id,
+ })
+ fortarget_idintarget_ids
+ ],ignore_conflicts=True)
+ return
+
+ missing_target_ids=self._get_missing_target_ids(
+ source_field_name,target_field_name,db,target_ids
+ )
+ withtransaction.atomic(using=db,savepoint=False):
+ ifmust_send_signals:
+ signals.m2m_changed.send(
+ sender=self.through,action='pre_add',
+ instance=self.instance,reverse=self.reverse,
+ model=self.model,pk_set=missing_target_ids,using=db,
+ )
+ # Add the ones that aren't there already.
+ self.through._default_manager.using(db).bulk_create([
+ self.through(**through_defaults,**{
+ '%s_id'%source_field_name:self.related_val[0],
+ '%s_id'%target_field_name:target_id,
+ })
+ fortarget_idinmissing_target_ids
+ ],ignore_conflicts=can_ignore_conflicts)
+
+ ifmust_send_signals:
+ signals.m2m_changed.send(
+ sender=self.through,action='post_add',
+ instance=self.instance,reverse=self.reverse,
+ model=self.model,pk_set=missing_target_ids,using=db,
+ )
+
+ def_remove_items(self,source_field_name,target_field_name,*objs):
+ # source_field_name: the PK colname in join table for the source object
+ # target_field_name: the PK colname in join table for the target object
+ # *objs - objects to remove. Either object instances, or primary
+ # keys of object instances.
+ ifnotobjs:
+ return
+
+ # Check that all the objects are of the right type
+ old_ids=set()
+ forobjinobjs:
+ ifisinstance(obj,self.model):
+ fk_val=self.target_field.get_foreign_related_value(obj)[0]
+ old_ids.add(fk_val)
+ else:
+ old_ids.add(obj)
+
+ db=router.db_for_write(self.through,instance=self.instance)
+ withtransaction.atomic(using=db,savepoint=False):
+ # Send a signal to the other end if need be.
+ signals.m2m_changed.send(
+ sender=self.through,action="pre_remove",
+ instance=self.instance,reverse=self.reverse,
+ model=self.model,pk_set=old_ids,using=db,
+ )
+ target_model_qs=super().get_queryset()
+ iftarget_model_qs._has_filters():
+ old_vals=target_model_qs.using(db).filter(**{
+ '%s__in'%self.target_field.target_field.attname:old_ids})
+ else:
+ old_vals=old_ids
+ filters=self._build_remove_filters(old_vals)
+ self.through._default_manager.using(db).filter(filters).delete()
+
+ signals.m2m_changed.send(
+ sender=self.through,action="post_remove",
+ instance=self.instance,reverse=self.reverse,
+ model=self.model,pk_set=old_ids,using=db,
+ )
+
+ returnManyRelatedManager
+
+"""
+Various data structures used in query construction.
+
+Factored out from django.db.models.query to avoid making the main module very
+large and/or so that they can be used by other modules without getting into
+circular import difficulties.
+"""
+importcopy
+importfunctools
+importinspect
+fromcollectionsimportnamedtuple
+
+fromdjango.core.exceptionsimportFieldError
+fromdjango.db.models.constantsimportLOOKUP_SEP
+fromdjango.utilsimporttree
+
+# PathInfo is used when converting lookups (fk__somecol). The contents
+# describe the relation in Model terms (model Options and Fields for both
+# sides of the relation. The join_field is the field backing the relation.
+PathInfo=namedtuple('PathInfo','from_opts to_opts target_fields join_field m2m direct filtered_relation')
+
+
+defsubclasses(cls):
+ yieldcls
+ forsubclassincls.__subclasses__():
+ yield fromsubclasses(subclass)
+
+
+classQ(tree.Node):
+ """
+ Encapsulate filters as objects that can then be combined logically (using
+ `&` and `|`).
+ """
+ # Connection types
+ AND='AND'
+ OR='OR'
+ default=AND
+ conditional=True
+
+ def__init__(self,*args,_connector=None,_negated=False,**kwargs):
+ super().__init__(children=[*args,*sorted(kwargs.items())],connector=_connector,negated=_negated)
+
+ def_combine(self,other,conn):
+ ifnot(isinstance(other,Q)orgetattr(other,'conditional',False)isTrue):
+ raiseTypeError(other)
+
+ ifnotself:
+ returnother.copy()ifhasattr(other,'copy')elsecopy.copy(other)
+ elifisinstance(other,Q)andnotother:
+ _,args,kwargs=self.deconstruct()
+ returntype(self)(*args,**kwargs)
+
+ obj=type(self)()
+ obj.connector=conn
+ obj.add(self,conn)
+ obj.add(other,conn)
+ returnobj
+
+ def__or__(self,other):
+ returnself._combine(other,self.OR)
+
+ def__and__(self,other):
+ returnself._combine(other,self.AND)
+
+ def__invert__(self):
+ obj=type(self)()
+ obj.add(self,self.AND)
+ obj.negate()
+ returnobj
+
+ defresolve_expression(self,query=None,allow_joins=True,reuse=None,summarize=False,for_save=False):
+ # We must promote any new joins to left outer joins so that when Q is
+ # used as an expression, rows aren't filtered due to joins.
+ clause,joins=query._add_q(
+ self,reuse,allow_joins=allow_joins,split_subq=False,
+ check_filterable=False,
+ )
+ query.promote_joins(joins)
+ returnclause
+
+ defdeconstruct(self):
+ path='%s.%s'%(self.__class__.__module__,self.__class__.__name__)
+ ifpath.startswith('django.db.models.query_utils'):
+ path=path.replace('django.db.models.query_utils','django.db.models')
+ args=tuple(self.children)
+ kwargs={}
+ ifself.connector!=self.default:
+ kwargs['_connector']=self.connector
+ ifself.negated:
+ kwargs['_negated']=True
+ returnpath,args,kwargs
+
+
+classDeferredAttribute:
+ """
+ A wrapper for a deferred-loading field. When the value is read from this
+ object the first time, the query is executed.
+ """
+ def__init__(self,field):
+ self.field=field
+
+ def__get__(self,instance,cls=None):
+ """
+ Retrieve and caches the value from the datastore on the first lookup.
+ Return the cached value.
+ """
+ ifinstanceisNone:
+ returnself
+ data=instance.__dict__
+ field_name=self.field.attname
+ iffield_namenotindata:
+ # Let's see if the field is part of the parent chain. If so we
+ # might be able to reuse the already loaded value. Refs #18343.
+ val=self._check_parent_chain(instance)
+ ifvalisNone:
+ instance.refresh_from_db(fields=[field_name])
+ else:
+ data[field_name]=val
+ returndata[field_name]
+
+ def_check_parent_chain(self,instance):
+ """
+ Check if the field value can be fetched from a parent field already
+ loaded in the instance. This can be done if the to-be fetched
+ field is a primary key field.
+ """
+ opts=instance._meta
+ link_field=opts.get_ancestor_link(self.field.model)
+ ifself.field.primary_keyandself.field!=link_field:
+ returngetattr(instance,link_field.attname)
+ returnNone
+
+
+classRegisterLookupMixin:
+
+ @classmethod
+ def_get_lookup(cls,lookup_name):
+ returncls.get_lookups().get(lookup_name,None)
+
+ @classmethod
+ @functools.lru_cache(maxsize=None)
+ defget_lookups(cls):
+ class_lookups=[parent.__dict__.get('class_lookups',{})forparentininspect.getmro(cls)]
+ returncls.merge_dicts(class_lookups)
+
+ defget_lookup(self,lookup_name):
+ fromdjango.db.models.lookupsimportLookup
+ found=self._get_lookup(lookup_name)
+ iffoundisNoneandhasattr(self,'output_field'):
+ returnself.output_field.get_lookup(lookup_name)
+ iffoundisnotNoneandnotissubclass(found,Lookup):
+ returnNone
+ returnfound
+
+ defget_transform(self,lookup_name):
+ fromdjango.db.models.lookupsimportTransform
+ found=self._get_lookup(lookup_name)
+ iffoundisNoneandhasattr(self,'output_field'):
+ returnself.output_field.get_transform(lookup_name)
+ iffoundisnotNoneandnotissubclass(found,Transform):
+ returnNone
+ returnfound
+
+ @staticmethod
+ defmerge_dicts(dicts):
+ """
+ Merge dicts in reverse to preference the order of the original list. e.g.,
+ merge_dicts([a, b]) will preference the keys in 'a' over those in 'b'.
+ """
+ merged={}
+ fordinreversed(dicts):
+ merged.update(d)
+ returnmerged
+
+ @classmethod
+ def_clear_cached_lookups(cls):
+ forsubclassinsubclasses(cls):
+ subclass.get_lookups.cache_clear()
+
+ @classmethod
+ defregister_lookup(cls,lookup,lookup_name=None):
+ iflookup_nameisNone:
+ lookup_name=lookup.lookup_name
+ if'class_lookups'notincls.__dict__:
+ cls.class_lookups={}
+ cls.class_lookups[lookup_name]=lookup
+ cls._clear_cached_lookups()
+ returnlookup
+
+ @classmethod
+ def_unregister_lookup(cls,lookup,lookup_name=None):
+ """
+ Remove given lookup from cls lookups. For use in tests only as it's
+ not thread-safe.
+ """
+ iflookup_nameisNone:
+ lookup_name=lookup.lookup_name
+ delcls.class_lookups[lookup_name]
+
+
+defselect_related_descend(field,restricted,requested,load_fields,reverse=False):
+ """
+ Return True if this field should be used to descend deeper for
+ select_related() purposes. Used by both the query construction code
+ (sql.query.fill_related_selections()) and the model instance creation code
+ (query.get_klass_info()).
+
+ Arguments:
+ * field - the field to be checked
+ * restricted - a boolean field, indicating if the field list has been
+ manually restricted using a requested clause)
+ * requested - The select_related() dictionary.
+ * load_fields - the set of fields to be loaded on this model
+ * reverse - boolean, True if we are checking a reverse select related
+ """
+ ifnotfield.remote_field:
+ returnFalse
+ iffield.remote_field.parent_linkandnotreverse:
+ returnFalse
+ ifrestricted:
+ ifreverseandfield.related_query_name()notinrequested:
+ returnFalse
+ ifnotreverseandfield.namenotinrequested:
+ returnFalse
+ ifnotrestrictedandfield.null:
+ returnFalse
+ ifload_fields:
+ iffield.attnamenotinload_fields:
+ ifrestrictedandfield.nameinrequested:
+ msg=(
+ 'Field %s.%s cannot be both deferred and traversed using '
+ 'select_related at the same time.'
+ )%(field.model._meta.object_name,field.name)
+ raiseFieldError(msg)
+ returnTrue
+
+
+defrefs_expression(lookup_parts,annotations):
+ """
+ Check if the lookup_parts contains references to the given annotations set.
+ Because the LOOKUP_SEP is contained in the default annotation names, check
+ each prefix of the lookup_parts for a match.
+ """
+ forninrange(1,len(lookup_parts)+1):
+ level_n_lookup=LOOKUP_SEP.join(lookup_parts[0:n])
+ iflevel_n_lookupinannotationsandannotations[level_n_lookup]:
+ returnannotations[level_n_lookup],lookup_parts[n:]
+ returnFalse,()
+
+
+defcheck_rel_lookup_compatibility(model,target_opts,field):
+ """
+ Check that self.model is compatible with target_opts. Compatibility
+ is OK if:
+ 1) model and opts match (where proxy inheritance is removed)
+ 2) model is parent of opts' model or the other way around
+ """
+ defcheck(opts):
+ return(
+ model._meta.concrete_model==opts.concrete_modelor
+ opts.concrete_modelinmodel._meta.get_parent_list()or
+ modelinopts.get_parent_list()
+ )
+ # If the field is a primary key, then doing a query against the field's
+ # model is ok, too. Consider the case:
+ # class Restaurant(models.Model):
+ # place = OneToOneField(Place, primary_key=True):
+ # Restaurant.objects.filter(pk__in=Restaurant.objects.all()).
+ # If we didn't have the primary key check, then pk__in (== place__in) would
+ # give Place's opts as the target opts, but Restaurant isn't compatible
+ # with that. This logic applies only to primary keys, as when doing __in=qs,
+ # we are going to turn this into __in=qs.values('pk') later on.
+ return(
+ check(target_opts)or
+ (getattr(field,'primary_key',False)andcheck(field.model._meta))
+ )
+
+
+classFilteredRelation:
+ """Specify custom filtering in the ON clause of SQL joins."""
+
+ def__init__(self,relation_name,*,condition=Q()):
+ ifnotrelation_name:
+ raiseValueError('relation_name cannot be empty.')
+ self.relation_name=relation_name
+ self.alias=None
+ ifnotisinstance(condition,Q):
+ raiseValueError('condition argument must be a Q() instance.')
+ self.condition=condition
+ self.path=[]
+
+ def__eq__(self,other):
+ ifnotisinstance(other,self.__class__):
+ returnNotImplemented
+ return(
+ self.relation_name==other.relation_nameand
+ self.alias==other.aliasand
+ self.condition==other.condition
+ )
+
+ defclone(self):
+ clone=FilteredRelation(self.relation_name,condition=self.condition)
+ clone.alias=self.alias
+ clone.path=self.path[:]
+ returnclone
+
+ defresolve_expression(self,*args,**kwargs):
+ """
+ QuerySet.annotate() only accepts expression-like arguments
+ (with a resolve_expression() method).
+ """
+ raiseNotImplementedError('FilteredRelation.resolve_expression() is unused.')
+
+ defas_sql(self,compiler,connection):
+ # Resolve the condition in Join.filtered_relation.
+ query=compiler.query
+ where=query.build_filtered_relation_q(self.condition,reuse=set(self.path))
+ returncompiler.compile(where)
+
+importcopy
+importitertools
+importoperator
+fromfunctoolsimporttotal_ordering,wraps
+
+
+classcached_property:
+ """
+ Decorator that converts a method with a single self argument into a
+ property cached on the instance.
+
+ A cached property can be made out of an existing method:
+ (e.g. ``url = cached_property(get_absolute_url)``).
+ The optional ``name`` argument is obsolete as of Python 3.6 and will be
+ deprecated in Django 4.0 (#30127).
+ """
+ name=None
+
+ @staticmethod
+ deffunc(instance):
+ raiseTypeError(
+ 'Cannot use cached_property instance without calling '
+ '__set_name__() on it.'
+ )
+
+ def__init__(self,func,name=None):
+ self.real_func=func
+ self.__doc__=getattr(func,'__doc__')
+
+ def__set_name__(self,owner,name):
+ ifself.nameisNone:
+ self.name=name
+ self.func=self.real_func
+ elifname!=self.name:
+ raiseTypeError(
+ "Cannot assign the same cached_property to two different names "
+ "(%r and %r)."%(self.name,name)
+ )
+
+ def__get__(self,instance,cls=None):
+ """
+ Call the function and put the return value in instance.__dict__ so that
+ subsequent attribute access on the instance returns the cached value
+ instead of calling cached_property.__get__().
+ """
+ ifinstanceisNone:
+ returnself
+ res=instance.__dict__[self.name]=self.func(instance)
+ returnres
+
+
+classclassproperty:
+ """
+ Decorator that converts a method with a single cls argument into a property
+ that can be accessed directly from the class.
+ """
+ def__init__(self,method=None):
+ self.fget=method
+
+ def__get__(self,instance,cls=None):
+ returnself.fget(cls)
+
+ defgetter(self,method):
+ self.fget=method
+ returnself
+
+
+classPromise:
+ """
+ Base class for the proxy class created in the closure of the lazy function.
+ It's used to recognize promises in code.
+ """
+ pass
+
+
+deflazy(func,*resultclasses):
+ """
+ Turn any callable into a lazy evaluated callable. result classes or types
+ is required -- at least one is needed so that the automatic forcing of
+ the lazy evaluation code is triggered. Results are not memoized; the
+ function is evaluated on every access.
+ """
+
+ @total_ordering
+ class__proxy__(Promise):
+ """
+ Encapsulate a function call and act as a proxy for methods that are
+ called on the result of that function. The function is not evaluated
+ until one of the methods on the result is called.
+ """
+ __prepared=False
+
+ def__init__(self,args,kw):
+ self.__args=args
+ self.__kw=kw
+ ifnotself.__prepared:
+ self.__prepare_class__()
+ self.__class__.__prepared=True
+
+ def__reduce__(self):
+ return(
+ _lazy_proxy_unpickle,
+ (func,self.__args,self.__kw)+resultclasses
+ )
+
+ def__repr__(self):
+ returnrepr(self.__cast())
+
+ @classmethod
+ def__prepare_class__(cls):
+ forresultclassinresultclasses:
+ fortype_inresultclass.mro():
+ formethod_nameintype_.__dict__:
+ # All __promise__ return the same wrapper method, they
+ # look up the correct implementation when called.
+ ifhasattr(cls,method_name):
+ continue
+ meth=cls.__promise__(method_name)
+ setattr(cls,method_name,meth)
+ cls._delegate_bytes=bytesinresultclasses
+ cls._delegate_text=strinresultclasses
+ ifcls._delegate_bytesandcls._delegate_text:
+ raiseValueError(
+ 'Cannot call lazy() with both bytes and text return types.'
+ )
+ ifcls._delegate_text:
+ cls.__str__=cls.__text_cast
+ elifcls._delegate_bytes:
+ cls.__bytes__=cls.__bytes_cast
+
+ @classmethod
+ def__promise__(cls,method_name):
+ # Builds a wrapper around some magic method
+ def__wrapper__(self,*args,**kw):
+ # Automatically triggers the evaluation of a lazy value and
+ # applies the given magic method of the result type.
+ res=func(*self.__args,**self.__kw)
+ returngetattr(res,method_name)(*args,**kw)
+ return__wrapper__
+
+ def__text_cast(self):
+ returnfunc(*self.__args,**self.__kw)
+
+ def__bytes_cast(self):
+ returnbytes(func(*self.__args,**self.__kw))
+
+ def__bytes_cast_encoded(self):
+ returnfunc(*self.__args,**self.__kw).encode()
+
+ def__cast(self):
+ ifself._delegate_bytes:
+ returnself.__bytes_cast()
+ elifself._delegate_text:
+ returnself.__text_cast()
+ else:
+ returnfunc(*self.__args,**self.__kw)
+
+ def__str__(self):
+ # object defines __str__(), so __prepare_class__() won't overload
+ # a __str__() method from the proxied class.
+ returnstr(self.__cast())
+
+ def__eq__(self,other):
+ ifisinstance(other,Promise):
+ other=other.__cast()
+ returnself.__cast()==other
+
+ def__lt__(self,other):
+ ifisinstance(other,Promise):
+ other=other.__cast()
+ returnself.__cast()<other
+
+ def__hash__(self):
+ returnhash(self.__cast())
+
+ def__mod__(self,rhs):
+ ifself._delegate_text:
+ returnstr(self)%rhs
+ returnself.__cast()%rhs
+
+ def__add__(self,other):
+ returnself.__cast()+other
+
+ def__radd__(self,other):
+ returnother+self.__cast()
+
+ def__deepcopy__(self,memo):
+ # Instances of this class are effectively immutable. It's just a
+ # collection of functions. So we don't need to do anything
+ # complicated for copying.
+ memo[id(self)]=self
+ returnself
+
+ @wraps(func)
+ def__wrapper__(*args,**kw):
+ # Creates the proxy object, instead of the actual value.
+ return__proxy__(args,kw)
+
+ return__wrapper__
+
+
+def_lazy_proxy_unpickle(func,args,kwargs,*resultclasses):
+ returnlazy(func,*resultclasses)(*args,**kwargs)
+
+
+deflazystr(text):
+ """
+ Shortcut for the common case of a lazy callable that returns str.
+ """
+ returnlazy(str,str)(text)
+
+
+defkeep_lazy(*resultclasses):
+ """
+ A decorator that allows a function to be called with one or more lazy
+ arguments. If none of the args are lazy, the function is evaluated
+ immediately, otherwise a __proxy__ is returned that will evaluate the
+ function when needed.
+ """
+ ifnotresultclasses:
+ raiseTypeError("You must pass at least one argument to keep_lazy().")
+
+ defdecorator(func):
+ lazy_func=lazy(func,*resultclasses)
+
+ @wraps(func)
+ defwrapper(*args,**kwargs):
+ ifany(isinstance(arg,Promise)forarginitertools.chain(args,kwargs.values())):
+ returnlazy_func(*args,**kwargs)
+ returnfunc(*args,**kwargs)
+ returnwrapper
+ returndecorator
+
+
+defkeep_lazy_text(func):
+ """
+ A decorator for functions that accept lazy arguments and return text.
+ """
+ returnkeep_lazy(str)(func)
+
+
+empty=object()
+
+
+defnew_method_proxy(func):
+ definner(self,*args):
+ ifself._wrappedisempty:
+ self._setup()
+ returnfunc(self._wrapped,*args)
+ returninner
+
+
+classLazyObject:
+ """
+ A wrapper for another class that can be used to delay instantiation of the
+ wrapped class.
+
+ By subclassing, you have the opportunity to intercept and alter the
+ instantiation. If you don't need to do that, use SimpleLazyObject.
+ """
+
+ # Avoid infinite recursion when tracing __init__ (#19456).
+ _wrapped=None
+
+ def__init__(self):
+ # Note: if a subclass overrides __init__(), it will likely need to
+ # override __copy__() and __deepcopy__() as well.
+ self._wrapped=empty
+
+ __getattr__=new_method_proxy(getattr)
+
+ def__setattr__(self,name,value):
+ ifname=="_wrapped":
+ # Assign to __dict__ to avoid infinite __setattr__ loops.
+ self.__dict__["_wrapped"]=value
+ else:
+ ifself._wrappedisempty:
+ self._setup()
+ setattr(self._wrapped,name,value)
+
+ def__delattr__(self,name):
+ ifname=="_wrapped":
+ raiseTypeError("can't delete _wrapped.")
+ ifself._wrappedisempty:
+ self._setup()
+ delattr(self._wrapped,name)
+
+ def_setup(self):
+ """
+ Must be implemented by subclasses to initialize the wrapped object.
+ """
+ raiseNotImplementedError('subclasses of LazyObject must provide a _setup() method')
+
+ # Because we have messed with __class__ below, we confuse pickle as to what
+ # class we are pickling. We're going to have to initialize the wrapped
+ # object to successfully pickle it, so we might as well just pickle the
+ # wrapped object since they're supposed to act the same way.
+ #
+ # Unfortunately, if we try to simply act like the wrapped object, the ruse
+ # will break down when pickle gets our id(). Thus we end up with pickle
+ # thinking, in effect, that we are a distinct object from the wrapped
+ # object, but with the same __dict__. This can cause problems (see #25389).
+ #
+ # So instead, we define our own __reduce__ method and custom unpickler. We
+ # pickle the wrapped object as the unpickler's argument, so that pickle
+ # will pickle it normally, and then the unpickler simply returns its
+ # argument.
+ def__reduce__(self):
+ ifself._wrappedisempty:
+ self._setup()
+ return(unpickle_lazyobject,(self._wrapped,))
+
+ def__copy__(self):
+ ifself._wrappedisempty:
+ # If uninitialized, copy the wrapper. Use type(self), not
+ # self.__class__, because the latter is proxied.
+ returntype(self)()
+ else:
+ # If initialized, return a copy of the wrapped object.
+ returncopy.copy(self._wrapped)
+
+ def__deepcopy__(self,memo):
+ ifself._wrappedisempty:
+ # We have to use type(self), not self.__class__, because the
+ # latter is proxied.
+ result=type(self)()
+ memo[id(self)]=result
+ returnresult
+ returncopy.deepcopy(self._wrapped,memo)
+
+ __bytes__=new_method_proxy(bytes)
+ __str__=new_method_proxy(str)
+ __bool__=new_method_proxy(bool)
+
+ # Introspection support
+ __dir__=new_method_proxy(dir)
+
+ # Need to pretend to be the wrapped class, for the sake of objects that
+ # care about this (especially in equality tests)
+ __class__=property(new_method_proxy(operator.attrgetter("__class__")))
+ __eq__=new_method_proxy(operator.eq)
+ __lt__=new_method_proxy(operator.lt)
+ __gt__=new_method_proxy(operator.gt)
+ __ne__=new_method_proxy(operator.ne)
+ __hash__=new_method_proxy(hash)
+
+ # List/Tuple/Dictionary methods support
+ __getitem__=new_method_proxy(operator.getitem)
+ __setitem__=new_method_proxy(operator.setitem)
+ __delitem__=new_method_proxy(operator.delitem)
+ __iter__=new_method_proxy(iter)
+ __len__=new_method_proxy(len)
+ __contains__=new_method_proxy(operator.contains)
+
+
+defunpickle_lazyobject(wrapped):
+ """
+ Used to unpickle lazy objects. Just return its argument, which will be the
+ wrapped object.
+ """
+ returnwrapped
+
+
+classSimpleLazyObject(LazyObject):
+ """
+ A lazy object initialized from any function.
+
+ Designed for compound objects of unknown type. For builtins or objects of
+ known type, use django.utils.functional.lazy.
+ """
+ def__init__(self,func):
+ """
+ Pass in a callable that returns the object to be wrapped.
+
+ If copies are made of the resulting SimpleLazyObject, which can happen
+ in various circumstances within Django, then you must ensure that the
+ callable can be safely run more than once and will return the same
+ value.
+ """
+ self.__dict__['_setupfunc']=func
+ super().__init__()
+
+ def_setup(self):
+ self._wrapped=self._setupfunc()
+
+ # Return a meaningful representation of the lazy object for debugging
+ # without evaluating the wrapped object.
+ def__repr__(self):
+ ifself._wrappedisempty:
+ repr_attr=self._setupfunc
+ else:
+ repr_attr=self._wrapped
+ return'<%s: %r>'%(type(self).__name__,repr_attr)
+
+ def__copy__(self):
+ ifself._wrappedisempty:
+ # If uninitialized, copy the wrapper. Use SimpleLazyObject, not
+ # self.__class__, because the latter is proxied.
+ returnSimpleLazyObject(self._setupfunc)
+ else:
+ # If initialized, return a copy of the wrapped object.
+ returncopy.copy(self._wrapped)
+
+ def__deepcopy__(self,memo):
+ ifself._wrappedisempty:
+ # We have to use SimpleLazyObject, not self.__class__, because the
+ # latter is proxied.
+ result=SimpleLazyObject(self._setupfunc)
+ memo[id(self)]=result
+ returnresult
+ returncopy.deepcopy(self._wrapped,memo)
+
+
+defpartition(predicate,values):
+ """
+ Split the values into two sets, based on the return value of the function
+ (True/False). e.g.:
+
+ >>> partition(lambda x: x > 3, range(5))
+ [0, 1, 2, 3], [4]
+ """
+ results=([],[])
+ foriteminvalues:
+ results[predicate(item)].append(item)
+ returnresults
+
+"""
+Evennia MUD/MUX/MU* creation system
+
+This is the main top-level API for Evennia. You can explore the evennia library
+by accessing evennia.<subpackage> directly. From inside the game you can read
+docs of all object by viewing its `__doc__` string, such as through
+
+ py evennia.ObjectDB.__doc__
+
+For full functionality you should explore this module via a django-
+aware shell. Go to your game directory and use the command
+
+ evennia shell
+
+to launch such a shell (using python or ipython depending on your install).
+See www.evennia.com for full documentation.
+
+"""
+
+# docstring header
+
+DOCSTRING="""
+Evennia MU* creation system.
+
+Online manual and API docs are found at http://www.evennia.com.
+
+Flat-API shortcut names:
+{}
+"""
+
+# Delayed loading of properties
+
+# Typeclasses
+
+DefaultAccount=None
+DefaultGuest=None
+DefaultObject=None
+DefaultCharacter=None
+DefaultRoom=None
+DefaultExit=None
+DefaultChannel=None
+DefaultScript=None
+
+# Database models
+ObjectDB=None
+AccountDB=None
+ScriptDB=None
+ChannelDB=None
+Msg=None
+
+# commands
+Command=None
+CmdSet=None
+default_cmds=None
+syscmdkeys=None
+InterruptCommand=None
+
+# search functions
+search_object=None
+search_script=None
+search_account=None
+search_channel=None
+search_message=None
+search_help=None
+search_tag=None
+
+# create functions
+create_object=None
+create_script=None
+create_account=None
+create_channel=None
+create_message=None
+create_help_entry=None
+
+# utilities
+settings=None
+lockfuncs=None
+inputhandler=None
+logger=None
+gametime=None
+ansi=None
+spawn=None
+managers=None
+contrib=None
+EvMenu=None
+EvTable=None
+EvForm=None
+EvEditor=None
+EvMore=None
+ANSIString=None
+signals=None
+
+# Handlers
+SESSION_HANDLER=None
+TASK_HANDLER=None
+TICKER_HANDLER=None
+MONITOR_HANDLER=None
+CHANNEL_HANDLER=None
+
+# Containers
+GLOBAL_SCRIPTS=None
+OPTION_CLASSES=None
+
+def_create_version():
+ """
+ Helper function for building the version string
+ """
+ importos
+ fromsubprocessimportcheck_output,CalledProcessError,STDOUT
+
+ version="Unknown"
+ root=os.path.dirname(os.path.abspath(__file__))
+ try:
+ withopen(os.path.join(root,"VERSION.txt"),"r")asf:
+ version=f.read().strip()
+ exceptIOErroraserr:
+ print(err)
+ try:
+ rev=(
+ check_output("git rev-parse --short HEAD",shell=True,cwd=root,stderr=STDOUT)
+ .strip()
+ .decode()
+ )
+ version="%s (rev %s)"%(version,rev)
+ except(IOError,CalledProcessError,OSError):
+ # ignore if we cannot get to git
+ pass
+ returnversion
+
+
+__version__=_create_version()
+del_create_version
+
+
+def_init():
+ """
+ This function is called automatically by the launcher only after
+ Evennia has fully initialized all its models. It sets up the API
+ in a safe environment where all models are available already.
+ """
+ globalDefaultAccount,DefaultObject,DefaultGuest,DefaultCharacter
+ globalDefaultRoom,DefaultExit,DefaultChannel,DefaultScript
+ globalObjectDB,AccountDB,ScriptDB,ChannelDB,Msg
+ globalCommand,CmdSet,default_cmds,syscmdkeys,InterruptCommand
+ globalsearch_object,search_script,search_account,search_channel
+ globalsearch_help,search_tag,search_message
+ globalcreate_object,create_script,create_account,create_channel
+ globalcreate_message,create_help_entry
+ globalsignals
+ globalsettings,lockfuncs,logger,utils,gametime,ansi,spawn,managers
+ globalcontrib,TICKER_HANDLER,MONITOR_HANDLER,SESSION_HANDLER
+ globalCHANNEL_HANDLER,TASK_HANDLER
+ globalGLOBAL_SCRIPTS,OPTION_CLASSES
+ globalEvMenu,EvTable,EvForm,EvMore,EvEditor
+ globalANSIString
+
+ # Parent typeclasses
+ from.accounts.accountsimportDefaultAccount
+ from.accounts.accountsimportDefaultGuest
+ from.objects.objectsimportDefaultObject
+ from.objects.objectsimportDefaultCharacter
+ from.objects.objectsimportDefaultRoom
+ from.objects.objectsimportDefaultExit
+ from.comms.commsimportDefaultChannel
+ from.scripts.scriptsimportDefaultScript
+
+ # Database models
+ from.objects.modelsimportObjectDB
+ from.accounts.modelsimportAccountDB
+ from.scripts.modelsimportScriptDB
+ from.comms.modelsimportChannelDB
+ from.comms.modelsimportMsg
+
+ # commands
+ from.commands.commandimportCommand,InterruptCommand
+ from.commands.cmdsetimportCmdSet
+
+ # search functions
+ from.utils.searchimportsearch_object
+ from.utils.searchimportsearch_script
+ from.utils.searchimportsearch_account
+ from.utils.searchimportsearch_message
+ from.utils.searchimportsearch_channel
+ from.utils.searchimportsearch_help
+ from.utils.searchimportsearch_tag
+
+ # create functions
+ from.utils.createimportcreate_object
+ from.utils.createimportcreate_script
+ from.utils.createimportcreate_account
+ from.utils.createimportcreate_channel
+ from.utils.createimportcreate_message
+ from.utils.createimportcreate_help_entry
+
+ # utilities
+ fromdjango.confimportsettings
+ from.locksimportlockfuncs
+ from.utilsimportlogger
+ from.utilsimportgametime
+ from.utilsimportansi
+ from.prototypes.spawnerimportspawn
+ from.importcontrib
+ from.utils.evmenuimportEvMenu
+ from.utils.evtableimportEvTable
+ from.utils.evmoreimportEvMore
+ from.utils.evformimportEvForm
+ from.utils.eveditorimportEvEditor
+ from.utils.ansiimportANSIString
+ from.serverimportsignals
+
+ # handlers
+ from.scripts.tickerhandlerimportTICKER_HANDLER
+ from.scripts.taskhandlerimportTASK_HANDLER
+ from.server.sessionhandlerimportSESSION_HANDLER
+ from.comms.channelhandlerimportCHANNEL_HANDLER
+ from.scripts.monitorhandlerimportMONITOR_HANDLER
+
+ # containers
+ from.utils.containersimportGLOBAL_SCRIPTS
+ from.utils.containersimportOPTION_CLASSES
+
+ # API containers
+
+ class_EvContainer(object):
+ """
+ Parent for other containers
+
+ """
+
+ def_help(self):
+ "Returns list of contents"
+ names=[namefornameinself.__class__.__dict__ifnotname.startswith("_")]
+ names+=[namefornameinself.__dict__ifnotname.startswith("_")]
+ print(self.__doc__+"-"*60+"\n"+", ".join(names))
+
+ help=property(_help)
+
+ classDBmanagers(_EvContainer):
+ """
+ Links to instantiated Django database managers. These are used
+ to perform more advanced custom database queries than the standard
+ search functions allow.
+
+ helpentries - HelpEntry.objects
+ accounts - AccountDB.objects
+ scripts - ScriptDB.objects
+ msgs - Msg.objects
+ channels - Channel.objects
+ objects - ObjectDB.objects
+ serverconfigs - ServerConfig.objects
+ tags - Tags.objects
+ attributes - Attributes.objects
+
+ """
+
+ from.help.modelsimportHelpEntry
+ from.accounts.modelsimportAccountDB
+ from.scripts.modelsimportScriptDB
+ from.comms.modelsimportMsg,ChannelDB
+ from.objects.modelsimportObjectDB
+ from.server.modelsimportServerConfig
+ from.typeclasses.attributesimportAttribute
+ from.typeclasses.tagsimportTag
+
+ # create container's properties
+ helpentries=HelpEntry.objects
+ accounts=AccountDB.objects
+ scripts=ScriptDB.objects
+ msgs=Msg.objects
+ channels=ChannelDB.objects
+ objects=ObjectDB.objects
+ serverconfigs=ServerConfig.objects
+ attributes=Attribute.objects
+ tags=Tag.objects
+ # remove these so they are not visible as properties
+ delHelpEntry,AccountDB,ScriptDB,Msg,ChannelDB
+ # del ExternalChannelConnection
+ delObjectDB,ServerConfig,Tag,Attribute
+
+ managers=DBmanagers()
+ delDBmanagers
+
+ classDefaultCmds(_EvContainer):
+ """
+ This container holds direct shortcuts to all default commands in Evennia.
+
+ To access in code, do 'from evennia import default_cmds' then
+ access the properties on the imported default_cmds object.
+
+ """
+
+ from.commands.default.cmdset_characterimportCharacterCmdSet
+ from.commands.default.cmdset_accountimportAccountCmdSet
+ from.commands.default.cmdset_unloggedinimportUnloggedinCmdSet
+ from.commands.default.cmdset_sessionimportSessionCmdSet
+ from.commands.default.muxcommandimportMuxCommand,MuxAccountCommand
+
+ def__init__(self):
+ "populate the object with commands"
+
+ defadd_cmds(module):
+ "helper method for populating this object with cmds"
+ fromevennia.utilsimportutils
+
+ cmdlist=utils.variable_from_module(module,module.__all__)
+ self.__dict__.update(dict([(c.__name__,c)forcincmdlist]))
+
+ from.commands.defaultimport(
+ admin,
+ batchprocess,
+ building,
+ comms,
+ general,
+ account,
+ help,
+ system,
+ unloggedin,
+ )
+
+ add_cmds(admin)
+ add_cmds(building)
+ add_cmds(batchprocess)
+ add_cmds(building)
+ add_cmds(comms)
+ add_cmds(general)
+ add_cmds(account)
+ add_cmds(help)
+ add_cmds(system)
+ add_cmds(unloggedin)
+
+ default_cmds=DefaultCmds()
+ delDefaultCmds
+
+ classSystemCmds(_EvContainer):
+ """
+ Creating commands with keys set to these constants will make
+ them system commands called as a replacement by the parser when
+ special situations occur. If not defined, the hard-coded
+ responses in the server are used.
+
+ CMD_NOINPUT - no input was given on command line
+ CMD_NOMATCH - no valid command key was found
+ CMD_MULTIMATCH - multiple command matches were found
+ CMD_CHANNEL - the command name is a channel name
+ CMD_LOGINSTART - this command will be called as the very
+ first command when an account connects to
+ the server.
+
+ To access in code, do 'from evennia import syscmdkeys' then
+ access the properties on the imported syscmdkeys object.
+
+ """
+
+ from.commandsimportcmdhandler
+
+ CMD_NOINPUT=cmdhandler.CMD_NOINPUT
+ CMD_NOMATCH=cmdhandler.CMD_NOMATCH
+ CMD_MULTIMATCH=cmdhandler.CMD_MULTIMATCH
+ CMD_CHANNEL=cmdhandler.CMD_CHANNEL
+ CMD_LOGINSTART=cmdhandler.CMD_LOGINSTART
+ delcmdhandler
+
+ syscmdkeys=SystemCmds()
+ delSystemCmds
+ del_EvContainer
+
+ # delayed starts - important so as to not back-access evennia before it has
+ # finished initializing
+ GLOBAL_SCRIPTS.start()
+ from.prototypesimportprototypes
+ prototypes.load_module_prototypes()
+ delprototypes
+
+
+
[docs]defset_trace(term_size=(140,80),debugger="auto"):
+ """
+ Helper function for running a debugger inside the Evennia event loop.
+
+ Args:
+ term_size (tuple, optional): Only used for Pudb and defines the size of the terminal
+ (width, height) in number of characters.
+ debugger (str, optional): One of 'auto', 'pdb' or 'pudb'. Pdb is the standard debugger. Pudb
+ is an external package with a different, more 'graphical', ncurses-based UI. With
+ 'auto', will use pudb if possible, otherwise fall back to pdb. Pudb is available through
+ `pip install pudb`.
+
+ Notes:
+ To use:
+
+ 1) add this to a line to act as a breakpoint for entering the debugger:
+
+ from evennia import set_trace; set_trace()
+
+ 2) restart evennia in interactive mode
+
+ evennia istart
+
+ 3) debugger will appear in the interactive terminal when breakpoint is reached. Exit
+ with 'q', remove the break line and restart server when finished.
+
+ """
+ importsys
+
+ dbg=None
+
+ ifdebuggerin("auto","pudb"):
+ try:
+ frompudbimportdebugger
+
+ dbg=debugger.Debugger(stdout=sys.__stdout__,term_size=term_size)
+ exceptImportError:
+ ifdebugger=="pudb":
+ raise
+ pass
+
+ ifnotdbg:
+ importpdb
+
+ dbg=pdb.Pdb(stdout=sys.__stdout__)
+
+ try:
+ # Start debugger, forcing it up one stack frame (otherwise `set_trace`
+ # will start debugger this point, not the actual code location)
+ dbg.set_trace(sys._getframe().f_back)
+ exceptException:
+ # Stopped at breakpoint. Press 'n' to continue into the code.
+ dbg.set_trace()
+"""
+Typeclass for Account objects
+
+Note that this object is primarily intended to
+store OOC information, not game info! This
+object represents the actual user (not their
+character) and has NO actual presence in the
+game world (this is handled by the associated
+character object, so you should customize that
+instead for most things).
+
+"""
+importre
+importtime
+fromdjango.confimportsettings
+fromdjango.contrib.authimportauthenticate,password_validation
+fromdjango.core.exceptionsimportImproperlyConfigured,ValidationError
+fromdjango.utilsimporttimezone
+fromdjango.utils.module_loadingimportimport_string
+fromevennia.typeclasses.modelsimportTypeclassBase
+fromevennia.accounts.managerimportAccountManager
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.objects.modelsimportObjectDB
+fromevennia.comms.modelsimportChannelDB
+fromevennia.server.modelsimportServerConfig
+fromevennia.server.throttleimportThrottle
+fromevennia.utilsimportclass_from_module,create,logger
+fromevennia.utils.utilsimportlazy_property,to_str,make_iter,is_iter,variable_from_module
+fromevennia.server.signalsimport(
+ SIGNAL_ACCOUNT_POST_CREATE,
+ SIGNAL_OBJECT_POST_PUPPET,
+ SIGNAL_OBJECT_POST_UNPUPPET,
+)
+fromevennia.typeclasses.attributesimportNickHandler
+fromevennia.scripts.scripthandlerimportScriptHandler
+fromevennia.commands.cmdsethandlerimportCmdSetHandler
+fromevennia.utils.optionhandlerimportOptionHandler
+
+fromdjango.utils.translationimportgettextas_
+fromrandomimportgetrandbits
+
+__all__=("DefaultAccount","DefaultGuest")
+
+_SESSIONS=None
+
+_AT_SEARCH_RESULT=variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".",1))
+_MULTISESSION_MODE=settings.MULTISESSION_MODE
+_MAX_NR_CHARACTERS=settings.MAX_NR_CHARACTERS
+_CMDSET_ACCOUNT=settings.CMDSET_ACCOUNT
+_MUDINFO_CHANNEL=None
+_CMDHANDLER=None
+
+# Create throttles for too many account-creations and login attempts
+CREATION_THROTTLE=Throttle(
+ limit=settings.CREATION_THROTTLE_LIMIT,timeout=settings.CREATION_THROTTLE_TIMEOUT
+)
+LOGIN_THROTTLE=Throttle(
+ limit=settings.LOGIN_THROTTLE_LIMIT,timeout=settings.LOGIN_THROTTLE_TIMEOUT
+)
+
+
+classAccountSessionHandler(object):
+ """
+ Manages the session(s) attached to an account.
+
+ """
+
+ def__init__(self,account):
+ """
+ Initializes the handler.
+
+ Args:
+ account (Account): The Account on which this handler is defined.
+
+ """
+ self.account=account
+
+ defget(self,sessid=None):
+ """
+ Get the sessions linked to this object.
+
+ Args:
+ sessid (int, optional): Specify a given session by
+ session id.
+
+ Returns:
+ sessions (list): A list of Session objects. If `sessid`
+ is given, this is a list with one (or zero) elements.
+
+ """
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+ ifsessid:
+ returnmake_iter(_SESSIONS.session_from_account(self.account,sessid))
+ else:
+ return_SESSIONS.sessions_from_account(self.account)
+
+ defall(self):
+ """
+ Alias to get(), returning all sessions.
+
+ Returns:
+ sessions (list): All sessions.
+
+ """
+ returnself.get()
+
+ defcount(self):
+ """
+ Get amount of sessions connected.
+
+ Returns:
+ sesslen (int): Number of sessions handled.
+
+ """
+ returnlen(self.get())
+
+
+
[docs]classDefaultAccount(AccountDB,metaclass=TypeclassBase):
+ """
+ This is the base Typeclass for all Accounts. Accounts represent
+ the person playing the game and tracks account info, password
+ etc. They are OOC entities without presence in-game. An Account
+ can connect to a Character Object in order to "enter" the
+ game.
+
+ Account Typeclass API:
+
+ * Available properties (only available on initiated typeclass objects)
+
+ - key (string) - name of account
+ - name (string)- wrapper for user.username
+ - aliases (list of strings) - aliases to the object. Will be saved to
+ database as AliasDB entries but returned as strings.
+ - dbref (int, read-only) - unique #id-number. Also "id" can be used.
+ - date_created (string) - time stamp of object creation
+ - permissions (list of strings) - list of permission strings
+ - user (User, read-only) - django User authorization object
+ - obj (Object) - game object controlled by account. 'character' can also
+ be used.
+ - sessions (list of Sessions) - sessions connected to this account
+ - is_superuser (bool, read-only) - if the connected user is a superuser
+
+ * Handlers
+
+ - locks - lock-handler: use locks.add() to add new lock strings
+ - db - attribute-handler: store/retrieve database attributes on this
+ self.db.myattr=val, val=self.db.myattr
+ - ndb - non-persistent attribute handler: same as db but does not
+ create a database entry when storing data
+ - scripts - script-handler. Add new scripts to object with scripts.add()
+ - cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object
+ - nicks - nick-handler. New nicks with nicks.add().
+
+ * Helper methods
+
+ - msg(text=None, from_obj=None, session=None, options=None, **kwargs)
+ - execute_cmd(raw_string)
+ - search(ostring, global_search=False, attribute_name=None,
+ use_nicks=False, location=None,
+ ignore_errors=False, account=False)
+ - is_typeclass(typeclass, exact=False)
+ - swap_typeclass(new_typeclass, clean_attributes=False, no_default=True)
+ - access(accessing_obj, access_type='read', default=False, no_superuser_bypass=False)
+ - check_permstring(permstring)
+
+ * Hook methods
+
+ basetype_setup()
+ at_account_creation()
+
+ > note that the following hooks are also found on Objects and are
+ usually handled on the character level:
+
+ - at_init()
+ - at_access()
+ - at_cmdset_get(**kwargs)
+ - at_first_login()
+ - at_post_login(session=None)
+ - at_disconnect()
+ - at_message_receive()
+ - at_message_send()
+ - at_server_reload()
+ - at_server_shutdown()
+
+ """
+
+ objects=AccountManager()
+
+ # properties
+
+
+ # Do not make this a lazy property; the web UI will not refresh it!
+ @property
+ defcharacters(self):
+ # Get playable characters list
+ objs=self.db._playable_charactersor[]
+
+ # Rebuild the list if legacy code left null values after deletion
+ try:
+ ifNoneinobjs:
+ objs=[xforxinself.db._playable_charactersifx]
+ self.db._playable_characters=objs
+ exceptExceptionase:
+ logger.log_trace(e)
+ logger.log_err(e)
+
+ returnobjs
+
+ # session-related methods
+
+
[docs]defdisconnect_session_from_account(self,session,reason=None):
+ """
+ Access method for disconnecting a given session from the
+ account (connection happens automatically in the
+ sessionhandler)
+
+ Args:
+ session (Session): Session to disconnect.
+ reason (str, optional): Eventual reason for the disconnect.
+
+ """
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+ _SESSIONS.disconnect(session,reason)
+
+ # puppeting operations
+
+
[docs]defpuppet_object(self,session,obj):
+ """
+ Use the given session to control (puppet) the given object (usually
+ a Character type).
+
+ Args:
+ session (Session): session to use for puppeting
+ obj (Object): the object to start puppeting
+
+ Raises:
+ RuntimeError: If puppeting is not possible, the
+ `exception.msg` will contain the reason.
+
+
+ """
+ # safety checks
+ ifnotobj:
+ raiseRuntimeError("Object not found")
+ ifnotsession:
+ raiseRuntimeError("Session not found")
+ ifself.get_puppet(session)==obj:
+ # already puppeting this object
+ self.msg(_("You are already puppeting this object."))
+ return
+ ifnotobj.access(self,"puppet"):
+ # no access
+ self.msg(_("You don't have permission to puppet '{key}'.").format(key=obj.key))
+ return
+ ifobj.account:
+ # object already puppeted
+ ifobj.account==self:
+ ifobj.sessions.count():
+ # we may take over another of our sessions
+ # output messages to the affected sessions
+ if_MULTISESSION_MODEin(1,3):
+ txt1=f"Sharing |c{obj.name}|n with another of your sessions."
+ txt2=f"|c{obj.name}|n|G is now shared from another of your sessions.|n"
+ self.msg(txt1,session=session)
+ self.msg(txt2,session=obj.sessions.all())
+ else:
+ txt1=f"Taking over |c{obj.name}|n from another of your sessions."
+ txt2=f"|c{obj.name}|n|R is now acted from another of your sessions.|n"
+ self.msg(_(txt1),session=session)
+ self.msg(_(txt2),session=obj.sessions.all())
+ self.unpuppet_object(obj.sessions.get())
+ elifobj.account.is_connected:
+ # controlled by another account
+ self.msg(_("|c{key}|R is already puppeted by another Account.").format(key=obj.key))
+ return
+
+ # do the puppeting
+ ifsession.puppet:
+ # cleanly unpuppet eventual previous object puppeted by this session
+ self.unpuppet_object(session)
+ # if we get to this point the character is ready to puppet or it
+ # was left with a lingering account/session reference from an unclean
+ # server kill or similar
+
+ obj.at_pre_puppet(self,session=session)
+
+ # do the connection
+ obj.sessions.add(session)
+ obj.account=self
+ session.puid=obj.id
+ session.puppet=obj
+ # validate/start persistent scripts on object
+ obj.scripts.validate()
+
+ # re-cache locks to make sure superuser bypass is updated
+ obj.locks.cache_lock_bypass(obj)
+ # final hook
+ obj.at_post_puppet()
+ SIGNAL_OBJECT_POST_PUPPET.send(sender=obj,account=self,session=session)
+
+
[docs]defunpuppet_object(self,session):
+ """
+ Disengage control over an object.
+
+ Args:
+ session (Session or list): The session or a list of
+ sessions to disengage from their puppets.
+
+ Raises:
+ RuntimeError With message about error.
+
+ """
+ forsessioninmake_iter(session):
+ obj=session.puppet
+ ifobj:
+ # do the disconnect, but only if we are the last session to puppet
+ obj.at_pre_unpuppet()
+ obj.sessions.remove(session)
+ ifnotobj.sessions.count():
+ delobj.account
+ obj.at_post_unpuppet(self,session=session)
+ SIGNAL_OBJECT_POST_UNPUPPET.send(sender=obj,session=session,account=self)
+ # Just to be sure we're always clear.
+ session.puppet=None
+ session.puid=None
+
+
[docs]defunpuppet_all(self):
+ """
+ Disconnect all puppets. This is called by server before a
+ reset/shutdown.
+ """
+ self.unpuppet_object(self.sessions.all())
+
+
[docs]defget_puppet(self,session):
+ """
+ Get an object puppeted by this session through this account. This is
+ the main method for retrieving the puppeted object from the
+ account's end.
+
+ Args:
+ session (Session): Find puppeted object based on this session
+
+ Returns:
+ puppet (Object): The matching puppeted object, if any.
+
+ """
+ returnsession.puppetifsessionelseNone
+
+
[docs]defget_all_puppets(self):
+ """
+ Get all currently puppeted objects.
+
+ Returns:
+ puppets (list): All puppeted objects currently controlled
+ by this Account.
+
+ """
+ returnlist(set(session.puppetforsessioninself.sessions.all()ifsession.puppet))
+
+ def__get_single_puppet(self):
+ """
+ This is a legacy convenience link for use with `MULTISESSION_MODE`.
+
+ Returns:
+ puppets (Object or list): Users of `MULTISESSION_MODE` 0 or 1 will
+ always get the first puppet back. Users of higher `MULTISESSION_MODE`s will
+ get a list of all puppeted objects.
+
+ """
+ puppets=self.get_all_puppets()
+ if_MULTISESSION_MODEin(0,1):
+ returnpuppetsandpuppets[0]orNone
+ returnpuppets
+
+ character=property(__get_single_puppet)
+ puppet=property(__get_single_puppet)
+
+ # utility methods
+
[docs]@classmethod
+ defis_banned(cls,**kwargs):
+ """
+ Checks if a given username or IP is banned.
+
+ Keyword Args:
+ ip (str, optional): IP address.
+ username (str, optional): Username.
+
+ Returns:
+ is_banned (bool): Whether either is banned or not.
+
+ """
+
+ ip=kwargs.get("ip","").strip()
+ username=kwargs.get("username","").lower().strip()
+
+ # Check IP and/or name bans
+ bans=ServerConfig.objects.conf("server_bans")
+ ifbansand(
+ any(tup[0]==usernamefortupinbansifusername)
+ orany(tup[2].match(ip)fortupinbansifipandtup[2])
+ ):
+ returnTrue
+
+ returnFalse
+
+
[docs]@classmethod
+ defget_username_validators(
+ cls,validator_config=getattr(settings,"AUTH_USERNAME_VALIDATORS",[])
+ ):
+ """
+ Retrieves and instantiates validators for usernames.
+
+ Args:
+ validator_config (list): List of dicts comprising the battery of
+ validators to apply to a username.
+
+ Returns:
+ validators (list): List of instantiated Validator objects.
+ """
+
+ objs=[]
+ forvalidatorinvalidator_config:
+ try:
+ klass=import_string(validator["NAME"])
+ exceptImportError:
+ msg=(
+ f"The module in NAME could not be imported: {validator['NAME']}. "
+ "Check your AUTH_USERNAME_VALIDATORS setting."
+ )
+ raiseImproperlyConfigured(msg)
+ objs.append(klass(**validator.get("OPTIONS",{})))
+ returnobjs
+
+
[docs]@classmethod
+ defauthenticate(cls,username,password,ip="",**kwargs):
+ """
+ Checks the given username/password against the database to see if the
+ credentials are valid.
+
+ Note that this simply checks credentials and returns a valid reference
+ to the user-- it does not log them in!
+
+ To finish the job:
+ After calling this from a Command, associate the account with a Session:
+ - session.sessionhandler.login(session, account)
+
+ ...or after calling this from a View, associate it with an HttpRequest:
+ - django.contrib.auth.login(account, request)
+
+ Args:
+ username (str): Username of account
+ password (str): Password of account
+ ip (str, optional): IP address of client
+
+ Keyword Args:
+ session (Session, optional): Session requesting authentication
+
+ Returns:
+ account (DefaultAccount, None): Account whose credentials were
+ provided if not banned.
+ errors (list): Error messages of any failures.
+
+ """
+ errors=[]
+ ifip:
+ ip=str(ip)
+
+ # See if authentication is currently being throttled
+ ifipandLOGIN_THROTTLE.check(ip):
+ errors.append(_("Too many login failures; please try again in a few minutes."))
+
+ # With throttle active, do not log continued hits-- it is a
+ # waste of storage and can be abused to make your logs harder to
+ # read and/or fill up your disk.
+ returnNone,errors
+
+ # Check IP and/or name bans
+ banned=cls.is_banned(username=username,ip=ip)
+ ifbanned:
+ # this is a banned IP or name!
+ errors.append(
+ _(
+ "|rYou have been banned and cannot continue from here."
+ "\nIf you feel this ban is in error, please email an admin.|x"
+ )
+ )
+ logger.log_sec(f"Authentication Denied (Banned): {username} (IP: {ip}).")
+ LOGIN_THROTTLE.update(ip,"Too many sightings of banned artifact.")
+ returnNone,errors
+
+ # Authenticate and get Account object
+ account=authenticate(username=username,password=password)
+ ifnotaccount:
+ # User-facing message
+ errors.append(_("Username and/or password is incorrect."))
+
+ # Log auth failures while throttle is inactive
+ logger.log_sec(f"Authentication Failure: {username} (IP: {ip}).")
+
+ # Update throttle
+ ifip:
+ LOGIN_THROTTLE.update(ip,"Too many authentication failures.")
+
+ # Try to call post-failure hook
+ session=kwargs.get("session",None)
+ ifsession:
+ account=AccountDB.objects.get_account_from_name(username)
+ ifaccount:
+ account.at_failed_login(session)
+
+ returnNone,errors
+
+ # Account successfully authenticated
+ logger.log_sec(f"Authentication Success: {account} (IP: {ip}).")
+ returnaccount,errors
+
+
[docs]@classmethod
+ defnormalize_username(cls,username):
+ """
+ Django: Applies NFKC Unicode normalization to usernames so that visually
+ identical characters with different Unicode code points are considered
+ identical.
+
+ (This deals with the Turkish "i" problem and similar
+ annoyances. Only relevant if you go out of your way to allow Unicode
+ usernames though-- Evennia accepts ASCII by default.)
+
+ In this case we're simply piggybacking on this feature to apply
+ additional normalization per Evennia's standards.
+ """
+ username=super(DefaultAccount,cls).normalize_username(username)
+
+ # strip excessive spaces in accountname
+ username=re.sub(r"\s+"," ",username).strip()
+
+ returnusername
+
+
[docs]@classmethod
+ defvalidate_username(cls,username):
+ """
+ Checks the given username against the username validator associated with
+ Account objects, and also checks the database to make sure it is unique.
+
+ Args:
+ username (str): Username to validate
+
+ Returns:
+ valid (bool): Whether or not the password passed validation
+ errors (list): Error messages of any failures
+
+ """
+ valid=[]
+ errors=[]
+
+ # Make sure we're at least using the default validator
+ validators=cls.get_username_validators()
+ ifnotvalidators:
+ validators=[cls.username_validator]
+
+ # Try username against all enabled validators
+ forvalidatorinvalidators:
+ try:
+ valid.append(notvalidator(username))
+ exceptValidationErrorase:
+ valid.append(False)
+ errors.extend(e.messages)
+
+ # Disqualify if any check failed
+ ifFalseinvalid:
+ valid=False
+ else:
+ valid=True
+
+ returnvalid,errors
+
+
[docs]@classmethod
+ defvalidate_password(cls,password,account=None):
+ """
+ Checks the given password against the list of Django validators enabled
+ in the server.conf file.
+
+ Args:
+ password (str): Password to validate
+
+ Keyword Args:
+ account (DefaultAccount, optional): Account object to validate the
+ password for. Optional, but Django includes some validators to
+ do things like making sure users aren't setting passwords to the
+ same value as their username. If left blank, these user-specific
+ checks are skipped.
+
+ Returns:
+ valid (bool): Whether or not the password passed validation
+ error (ValidationError, None): Any validation error(s) raised. Multiple
+ errors can be nested within a single object.
+
+ """
+ valid=False
+ error=None
+
+ # Validation returns None on success; invert it and return a more sensible bool
+ try:
+ valid=notpassword_validation.validate_password(password,user=account)
+ exceptValidationErrorase:
+ error=e
+
+ returnvalid,error
+
+
[docs]defset_password(self,password,**kwargs):
+ """
+ Applies the given password to the account. Logs and triggers the `at_password_change` hook.
+
+ Args:
+ password (str): Password to set.
+
+ Notes:
+ This is called by Django also when logging in; it should not be mixed up with validation, since that
+ would mean old passwords in the database (pre validation checks) could get invalidated.
+
+ """
+ super(DefaultAccount,self).set_password(password)
+ logger.log_sec(f"Password successfully changed for {self}.")
+ self.at_password_change()
+
+
[docs]defcreate_character(self,*args,**kwargs):
+ """
+ Create a character linked to this account.
+
+ Args:
+ key (str, optional): If not given, use the same name as the account.
+ typeclass (str, optional): Typeclass to use for this character. If
+ not given, use settings.BASE_CHARACTER_TYPECLASS.
+ permissions (list, optional): If not given, use the account's permissions.
+ ip (str, optiona): The client IP creating this character. Will fall back to the
+ one stored for the account if not given.
+ kwargs (any): Other kwargs will be used in the create_call.
+ Returns:
+ Object: A new character of the `character_typeclass` type. None on an error.
+ list or None: A list of errors, or None.
+
+ """
+ # parse inputs
+ character_key=kwargs.pop("key",self.key)
+ character_ip=kwargs.pop("ip",self.db.creator_ip)
+ character_permissions=kwargs.pop("permissions",self.permissions)
+
+ # Load the appropriate Character class
+ character_typeclass=kwargs.pop("typeclass",None)
+ character_typeclass=(
+ character_typeclassifcharacter_typeclasselsesettings.BASE_CHARACTER_TYPECLASS
+ )
+ Character=class_from_module(character_typeclass)
+
+ if"location"notinkwargs:
+ kwargs["location"]=ObjectDB.objects.get_id(settings.START_LOCATION)
+
+ # Create the character
+ character,errs=Character.create(
+ character_key,
+ self,
+ ip=character_ip,
+ typeclass=character_typeclass,
+ permissions=character_permissions,
+ **kwargs,
+ )
+ ifcharacter:
+ # Update playable character list
+ ifcharacternotinself.characters:
+ self.db._playable_characters.append(character)
+
+ # We need to set this to have @ic auto-connect to this character
+ self.db._last_puppet=character
+ returncharacter,errs
+
+
[docs]@classmethod
+ defcreate(cls,*args,**kwargs):
+ """
+ Creates an Account (or Account/Character pair for MULTISESSION_MODE<2)
+ with default (or overridden) permissions and having joined them to the
+ appropriate default channels.
+
+ Keyword Args:
+ username (str): Username of Account owner
+ password (str): Password of Account owner
+ email (str, optional): Email address of Account owner
+ ip (str, optional): IP address of requesting connection
+ guest (bool, optional): Whether or not this is to be a Guest account
+
+ permissions (str, optional): Default permissions for the Account
+ typeclass (str, optional): Typeclass to use for new Account
+ character_typeclass (str, optional): Typeclass to use for new char
+ when applicable.
+
+ Returns:
+ account (Account): Account if successfully created; None if not
+ errors (list): List of error messages in string form
+
+ """
+
+ account=None
+ errors=[]
+
+ username=kwargs.get("username")
+ password=kwargs.get("password")
+ email=kwargs.get("email","").strip()
+ guest=kwargs.get("guest",False)
+
+ permissions=kwargs.get("permissions",settings.PERMISSION_ACCOUNT_DEFAULT)
+ typeclass=kwargs.get("typeclass",cls)
+
+ ip=kwargs.get("ip","")
+ ifipandCREATION_THROTTLE.check(ip):
+ errors.append(
+ _("You are creating too many accounts. Please log into an existing account.")
+ )
+ returnNone,errors
+
+ # Normalize username
+ username=cls.normalize_username(username)
+
+ # Validate username
+ ifnotguest:
+ valid,errs=cls.validate_username(username)
+ ifnotvalid:
+ # this echoes the restrictions made by django's auth
+ # module (except not allowing spaces, for convenience of
+ # logging in).
+ errors.extend(errs)
+ returnNone,errors
+
+ # Validate password
+ # Have to create a dummy Account object to check username similarity
+ valid,errs=cls.validate_password(password,account=cls(username=username))
+ ifnotvalid:
+ errors.extend(errs)
+ returnNone,errors
+
+ # Check IP and/or name bans
+ banned=cls.is_banned(username=username,ip=ip)
+ ifbanned:
+ # this is a banned IP or name!
+ string=_(
+ "|rYou have been banned and cannot continue from here."
+ "\nIf you feel this ban is in error, please email an admin.|x"
+ )
+ errors.append(string)
+ returnNone,errors
+
+ # everything's ok. Create the new account.
+ try:
+ try:
+ account=create.create_account(
+ username,email,password,permissions=permissions,typeclass=typeclass
+ )
+ logger.log_sec(f"Account Created: {account} (IP: {ip}).")
+
+ exceptExceptionase:
+ errors.append(
+ _(
+ "There was an error creating the Account. If this problem persists, contact an admin."
+ )
+ )
+ logger.log_trace()
+ returnNone,errors
+
+ # This needs to be set so the engine knows this account is
+ # logging in for the first time. (so it knows to call the right
+ # hooks during login later)
+ account.db.FIRST_LOGIN=True
+
+ # Record IP address of creation, if available
+ ifip:
+ account.db.creator_ip=ip
+
+ # join the new account to the public channel
+ pchannel=ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"])
+ ifnotpchannelornotpchannel.connect(account):
+ string=f"New account '{account.key}' could not connect to public channel!"
+ errors.append(string)
+ logger.log_err(string)
+
+ ifaccountandsettings.MULTISESSION_MODE<2:
+ # Auto-create a character to go with this account
+
+ character,errs=account.create_character(
+ typeclass=kwargs.get("character_typeclass")
+ )
+ iferrs:
+ errors.extend(errs)
+
+ exceptException:
+ # We are in the middle between logged in and -not, so we have
+ # to handle tracebacks ourselves at this point. If we don't,
+ # we won't see any errors at all.
+ errors.append(_("An error occurred. Please e-mail an admin if the problem persists."))
+ logger.log_trace()
+
+ # Update the throttle to indicate a new account was created from this IP
+ ifipandnotguest:
+ CREATION_THROTTLE.update(ip,"Too many accounts being created.")
+ SIGNAL_ACCOUNT_POST_CREATE.send(sender=account,ip=ip)
+ returnaccount,errors
+
+
[docs]defdelete(self,*args,**kwargs):
+ """
+ Deletes the account permanently.
+
+ Notes:
+ `*args` and `**kwargs` are passed on to the base delete
+ mechanism (these are usually not used).
+
+ """
+ forsessioninself.sessions.all():
+ # unpuppeting all objects and disconnecting the user, if any
+ # sessions remain (should usually be handled from the
+ # deleting command)
+ try:
+ self.unpuppet_object(session)
+ exceptRuntimeError:
+ # no puppet to disconnect from
+ pass
+ session.sessionhandler.disconnect(session,reason=_("Account being deleted."))
+ self.scripts.stop()
+ self.attributes.clear()
+ self.nicks.clear()
+ self.aliases.clear()
+ super().delete(*args,**kwargs)
+
+ # methods inherited from database model
+
+
[docs]defmsg(self,text=None,from_obj=None,session=None,options=None,**kwargs):
+ """
+ Evennia -> User
+ This is the main route for sending data back to the user from the
+ server.
+
+ Args:
+ text (str or tuple, optional): The message to send. This
+ is treated internally like any send-command, so its
+ value can be a tuple if sending multiple arguments to
+ the `text` oob command.
+ from_obj (Object or Account or list, optional): Object sending. If given, its
+ at_msg_send() hook will be called. If iterable, call on all entities.
+ session (Session or list, optional): Session object or a list of
+ Sessions to receive this send. If given, overrules the
+ default send behavior for the current
+ MULTISESSION_MODE.
+ options (list): Protocol-specific options. Passed on to the protocol.
+ Keyword Args:
+ any (dict): All other keywords are passed on to the protocol.
+
+ """
+ iffrom_obj:
+ # call hook
+ forobjinmake_iter(from_obj):
+ try:
+ obj.at_msg_send(text=text,to_obj=self,**kwargs)
+ exceptException:
+ # this may not be assigned.
+ logger.log_trace()
+ try:
+ ifnotself.at_msg_receive(text=text,**kwargs):
+ # abort message to this account
+ return
+ exceptException:
+ # this may not be assigned.
+ pass
+
+ kwargs["options"]=options
+
+ iftextisnotNone:
+ ifnot(isinstance(text,str)orisinstance(text,tuple)):
+ # sanitize text before sending across the wire
+ try:
+ text=to_str(text)
+ exceptException:
+ text=repr(text)
+ kwargs["text"]=text
+
+ # session relay
+ sessions=make_iter(session)ifsessionelseself.sessions.all()
+ forsessioninsessions:
+ session.data_out(**kwargs)
+
+
[docs]defexecute_cmd(self,raw_string,session=None,**kwargs):
+ """
+ Do something as this account. This method is never called normally,
+ but only when the account object itself is supposed to execute the
+ command. It takes account nicks into account, but not nicks of
+ eventual puppets.
+
+ Args:
+ raw_string (str): Raw command input coming from the command line.
+ session (Session, optional): The session to be responsible
+ for the command-send
+
+ Keyword Args:
+ kwargs (any): Other keyword arguments will be added to the
+ found command object instance as variables before it
+ executes. This is unused by default Evennia but may be
+ used to set flags and change operating paramaters for
+ commands at run-time.
+
+ """
+ # break circular import issues
+ global_CMDHANDLER
+ ifnot_CMDHANDLER:
+ fromevennia.commands.cmdhandlerimportcmdhandleras_CMDHANDLER
+ raw_string=self.nicks.nickreplace(
+ raw_string,categories=("inputline","channel"),include_account=False
+ )
+ ifnotsessionand_MULTISESSION_MODEin(0,1):
+ # for these modes we use the first/only session
+ sessions=self.sessions.get()
+ session=sessions[0]ifsessionselseNone
+
+ return_CMDHANDLER(
+ self,raw_string,callertype="account",session=session,**kwargs
+ )
+
+
[docs]defsearch(
+ self,
+ searchdata,
+ return_puppet=False,
+ search_object=False,
+ typeclass=None,
+ nofound_string=None,
+ multimatch_string=None,
+ use_nicks=True,
+ quiet=False,
+ **kwargs,
+ ):
+ """
+ This is similar to `DefaultObject.search` but defaults to searching
+ for Accounts only.
+
+ Args:
+ searchdata (str or int): Search criterion, the Account's
+ key or dbref to search for.
+ return_puppet (bool, optional): Instructs the method to
+ return matches as the object the Account controls rather
+ than the Account itself (or None) if nothing is puppeted).
+ search_object (bool, optional): Search for Objects instead of
+ Accounts. This is used by e.g. the @examine command when
+ wanting to examine Objects while OOC.
+ typeclass (Account typeclass, optional): Limit the search
+ only to this particular typeclass. This can be used to
+ limit to specific account typeclasses or to limit the search
+ to a particular Object typeclass if `search_object` is True.
+ nofound_string (str, optional): A one-time error message
+ to echo if `searchdata` leads to no matches. If not given,
+ will fall back to the default handler.
+ multimatch_string (str, optional): A one-time error
+ message to echo if `searchdata` leads to multiple matches.
+ If not given, will fall back to the default handler.
+ use_nicks (bool, optional): Use account-level nick replacement.
+ quiet (bool, optional): If set, will not show any error to the user,
+ and will also lead to returning a list of matches.
+
+ Return:
+ match (Account, Object or None): A single Account or Object match.
+ list: If `quiet=True` this is a list of 0, 1 or more Account or Object matches.
+
+ Notes:
+ Extra keywords are ignored, but are allowed in call in
+ order to make API more consistent with
+ objects.objects.DefaultObject.search.
+
+ """
+ # handle me, self and *me, *self
+ ifisinstance(searchdata,str):
+ # handle wrapping of common terms
+ ifsearchdata.lower()in("me","*me","self","*self"):
+ returnself
+ searchdata=self.nicks.nickreplace(
+ searchdata,categories=("account",),include_account=False
+ )
+ ifsearch_object:
+ matches=ObjectDB.objects.object_search(searchdata,typeclass=typeclass)
+ else:
+ matches=AccountDB.objects.account_search(searchdata,typeclass=typeclass)
+
+ ifquiet:
+ matches=list(matches)
+ ifreturn_puppet:
+ matches=[match.puppetformatchinmatches]
+ else:
+ matches=_AT_SEARCH_RESULT(
+ matches,
+ self,
+ query=searchdata,
+ nofound_string=nofound_string,
+ multimatch_string=multimatch_string,
+ )
+ ifmatchesandreturn_puppet:
+ try:
+ matches=matches.puppet
+ exceptAttributeError:
+ returnNone
+ returnmatches
+
+
[docs]defaccess(
+ self,accessing_obj,access_type="read",default=False,no_superuser_bypass=False,**kwargs
+ ):
+ """
+ Determines if another object has permission to access this
+ object in whatever way.
+
+ Args:
+ accessing_obj (Object): Object trying to access this one.
+ access_type (str, optional): Type of access sought.
+ default (bool, optional): What to return if no lock of
+ access_type was found
+ no_superuser_bypass (bool, optional): Turn off superuser
+ lock bypassing. Be careful with this one.
+
+ Keyword Args:
+ kwargs (any): Passed to the at_access hook along with the result.
+
+ Returns:
+ result (bool): Result of access check.
+
+ """
+ result=super().access(
+ accessing_obj,
+ access_type=access_type,
+ default=default,
+ no_superuser_bypass=no_superuser_bypass,
+ )
+ self.at_access(result,accessing_obj,access_type,**kwargs)
+ returnresult
+
+ @property
+ defidle_time(self):
+ """
+ Returns the idle time of the least idle session in seconds. If
+ no sessions are connected it returns nothing.
+ """
+ idle=[session.cmd_last_visibleforsessioninself.sessions.all()]
+ ifidle:
+ returntime.time()-float(max(idle))
+ returnNone
+
+ @property
+ defconnection_time(self):
+ """
+ Returns the maximum connection time of all connected sessions
+ in seconds. Returns nothing if there are no sessions.
+ """
+ conn=[session.conn_timeforsessioninself.sessions.all()]
+ ifconn:
+ returntime.time()-float(min(conn))
+ returnNone
+
+ # account hooks
+
+
[docs]defbasetype_setup(self):
+ """
+ This sets up the basic properties for an account. Overload this
+ with at_account_creation rather than changing this method.
+
+ """
+ # A basic security setup
+ lockstring=(
+ "examine:perm(Admin);edit:perm(Admin);"
+ "delete:perm(Admin);boot:perm(Admin);msg:all();"
+ "noidletimeout:perm(Builder) or perm(noidletimeout)"
+ )
+ self.locks.add(lockstring)
+
+ # The ooc account cmdset
+ self.cmdset.add_default(_CMDSET_ACCOUNT,permanent=True)
+
+
[docs]defat_account_creation(self):
+ """
+ This is called once, the very first time the account is created
+ (i.e. first time they register with the game). It's a good
+ place to store attributes all accounts should have, like
+ configuration values etc.
+
+ """
+ # set an (empty) attribute holding the characters this account has
+ lockstring="attrread:perm(Admins);attredit:perm(Admins);""attrcreate:perm(Admins);"
+ self.attributes.add("_playable_characters",[],lockstring=lockstring)
+ self.attributes.add("_saved_protocol_flags",{},lockstring=lockstring)
+
+
[docs]defat_init(self):
+ """
+ This is always called whenever this object is initiated --
+ that is, whenever it its typeclass is cached from memory. This
+ happens on-demand first time the object is used or activated
+ in some way after being created but also after each server
+ restart or reload. In the case of account objects, this usually
+ happens the moment the account logs in or reconnects after a
+ reload.
+
+ """
+ pass
+
+ # Note that the hooks below also exist in the character object's
+ # typeclass. You can often ignore these and rely on the character
+ # ones instead, unless you are implementing a multi-character game
+ # and have some things that should be done regardless of which
+ # character is currently connected to this account.
+
+
[docs]defat_first_save(self):
+ """
+ This is a generic hook called by Evennia when this object is
+ saved to the database the very first time. You generally
+ don't override this method but the hooks called by it.
+
+ """
+ self.basetype_setup()
+ self.at_account_creation()
+
+ permissions=[settings.PERMISSION_ACCOUNT_DEFAULT]
+ ifhasattr(self,"_createdict"):
+ # this will only be set if the utils.create_account
+ # function was used to create the object.
+ cdict=self._createdict
+ updates=[]
+ ifnotcdict.get("key"):
+ ifnotself.db_key:
+ self.db_key=f"#{self.dbid}"
+ updates.append("db_key")
+ elifself.key!=cdict.get("key"):
+ updates.append("db_key")
+ self.db_key=cdict["key"]
+ ifupdates:
+ self.save(update_fields=updates)
+
+ ifcdict.get("locks"):
+ self.locks.add(cdict["locks"])
+ ifcdict.get("permissions"):
+ permissions=cdict["permissions"]
+ ifcdict.get("tags"):
+ # this should be a list of tags, tuples (key, category) or (key, category, data)
+ self.tags.batch_add(*cdict["tags"])
+ ifcdict.get("attributes"):
+ # this should be tuples (key, val, ...)
+ self.attributes.batch_add(*cdict["attributes"])
+ ifcdict.get("nattributes"):
+ # this should be a dict of nattrname:value
+ forkey,valueincdict["nattributes"]:
+ self.nattributes.add(key,value)
+ delself._createdict
+
+ self.permissions.batch_add(*permissions)
+
+
[docs]defat_access(self,result,accessing_obj,access_type,**kwargs):
+ """
+ This is triggered after an access-call on this Account has
+ completed.
+
+ Args:
+ result (bool): The result of the access check.
+ accessing_obj (any): The object requesting the access
+ check.
+ access_type (str): The type of access checked.
+
+ Keyword Args:
+ kwargs (any): These are passed on from the access check
+ and can be used to relay custom instructions from the
+ check mechanism.
+
+ Notes:
+ This method cannot affect the result of the lock check and
+ its return value is not used in any way. It can be used
+ e.g. to customize error messages in a central location or
+ create other effects based on the access result.
+
+ """
+ pass
+
+
[docs]defat_cmdset_get(self,**kwargs):
+ """
+ Called just *before* cmdsets on this account are requested by
+ the command handler. The cmdsets are available as
+ `self.cmdset`. If changes need to be done on the fly to the
+ cmdset before passing them on to the cmdhandler, this is the
+ place to do it. This is called also if the account currently
+ have no cmdsets. kwargs are usually not used unless the
+ cmdset is generated dynamically.
+
+ """
+ pass
+
+
[docs]defat_first_login(self,**kwargs):
+ """
+ Called the very first time this account logs into the game.
+ Note that this is called *before* at_pre_login, so no session
+ is established and usually no character is yet assigned at
+ this point. This hook is intended for account-specific setup
+ like configurations.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_password_change(self,**kwargs):
+ """
+ Called after a successful password set/modify.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_pre_login(self,**kwargs):
+ """
+ Called every time the user logs in, just before the actual
+ login-state is set.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+ def_send_to_connect_channel(self,message):
+ """
+ Helper method for loading and sending to the comm channel
+ dedicated to connection messages.
+
+ Args:
+ message (str): A message to send to the connect channel.
+
+ """
+ global_MUDINFO_CHANNEL
+ ifnot_MUDINFO_CHANNEL:
+ try:
+ _MUDINFO_CHANNEL=ChannelDB.objects.filter(db_key=settings.CHANNEL_MUDINFO["key"])[
+ 0
+ ]
+ exceptException:
+ logger.log_trace()
+ ifsettings.USE_TZ:
+ now=timezone.localtime()
+ else:
+ now=timezone.now()
+ now="%02i-%02i-%02i(%02i:%02i)"%(now.year,now.month,now.day,now.hour,now.minute)
+ if_MUDINFO_CHANNEL:
+ _MUDINFO_CHANNEL.tempmsg(f"[{_MUDINFO_CHANNEL.key}, {now}]: {message}")
+ else:
+ logger.log_info(f"[{now}]: {message}")
+
+
[docs]defat_post_login(self,session=None,**kwargs):
+ """
+ Called at the end of the login process, just before letting
+ the account loose.
+
+ Args:
+ session (Session, optional): Session logging in, if any.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This is called *before* an eventual Character's
+ `at_post_login` hook. By default it is used to set up
+ auto-puppeting based on `MULTISESSION_MODE`.
+
+ """
+ # if we have saved protocol flags on ourselves, load them here.
+ protocol_flags=self.attributes.get("_saved_protocol_flags",{})
+ ifsessionandprotocol_flags:
+ session.update_flags(**protocol_flags)
+
+ # inform the client that we logged in through an OOB message
+ ifsession:
+ session.msg(logged_in={})
+
+ self._send_to_connect_channel(_("|G{key} connected|n").format(key=self.key))
+ if_MULTISESSION_MODE==0:
+ # in this mode we should have only one character available. We
+ # try to auto-connect to our last conneted object, if any
+ try:
+ self.puppet_object(session,self.db._last_puppet)
+ exceptRuntimeError:
+ self.msg(_("The Character does not exist."))
+ return
+ elif_MULTISESSION_MODE==1:
+ # in this mode all sessions connect to the same puppet.
+ try:
+ self.puppet_object(session,self.db._last_puppet)
+ exceptRuntimeError:
+ self.msg(_("The Character does not exist."))
+ return
+ elif_MULTISESSION_MODEin(2,3):
+ # In this mode we by default end up at a character selection
+ # screen. We execute look on the account.
+ # we make sure to clean up the _playable_characters list in case
+ # any was deleted in the interim.
+ self.db._playable_characters=[charforcharinself.db._playable_charactersifchar]
+ self.msg(
+ self.at_look(target=self.db._playable_characters,session=session),session=session
+ )
+
+
[docs]defat_failed_login(self,session,**kwargs):
+ """
+ Called by the login process if a user account is targeted correctly
+ but provided with an invalid password. By default it does nothing,
+ but exists to be overriden.
+
+ Args:
+ session (session): Session logging in.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+ """
+ pass
+
+
[docs]defat_disconnect(self,reason=None,**kwargs):
+ """
+ Called just before user is disconnected.
+
+ Args:
+ reason (str, optional): The reason given for the disconnect,
+ (echoed to the connection channel by default).
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+
+ """
+ reason=f" ({reasonifreasonelse''})"
+ self._send_to_connect_channel(
+ _("|R{key} disconnected{reason}|n").format(key=self.key,reason=reason)
+ )
+
+
[docs]defat_post_disconnect(self,**kwargs):
+ """
+ This is called *after* disconnection is complete. No messages
+ can be relayed to the account from here. After this call, the
+ account should not be accessed any more, making this a good
+ spot for deleting it (in the case of a guest account account,
+ for example).
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_msg_receive(self,text=None,from_obj=None,**kwargs):
+ """
+ This hook is called whenever someone sends a message to this
+ object using the `msg` method.
+
+ Note that from_obj may be None if the sender did not include
+ itself as an argument to the obj.msg() call - so you have to
+ check for this. .
+
+ Consider this a pre-processing method before msg is passed on
+ to the user session. If this method returns False, the msg
+ will not be passed on.
+
+ Args:
+ text (str, optional): The message received.
+ from_obj (any, optional): The object sending the message.
+
+ Keyword Args:
+ This includes any keywords sent to the `msg` method.
+
+ Returns:
+ receive (bool): If this message should be received.
+
+ Notes:
+ If this method returns False, the `msg` operation
+ will abort without sending the message.
+
+ """
+ returnTrue
+
+
[docs]defat_msg_send(self,text=None,to_obj=None,**kwargs):
+ """
+ This is a hook that is called when *this* object sends a
+ message to another object with `obj.msg(text, to_obj=obj)`.
+
+ Args:
+ text (str, optional): Text to send.
+ to_obj (any, optional): The object to send to.
+
+ Keyword Args:
+ Keywords passed from msg()
+
+ Notes:
+ Since this method is executed by `from_obj`, if no `from_obj`
+ was passed to `DefaultCharacter.msg` this hook will never
+ get called.
+
+ """
+ pass
+
+
[docs]defat_server_reload(self):
+ """
+ This hook is called whenever the server is shutting down for
+ restart/reboot. If you want to, for example, save
+ non-persistent properties across a restart, this is the place
+ to do it.
+ """
+ pass
+
+
[docs]defat_server_shutdown(self):
+ """
+ This hook is called whenever the server is shutting down fully
+ (i.e. not for a restart).
+ """
+ pass
+
+
[docs]defat_look(self,target=None,session=None,**kwargs):
+ """
+ Called when this object executes a look. It allows to customize
+ just what this means.
+
+ Args:
+ target (Object or list, optional): An object or a list
+ objects to inspect.
+ session (Session, optional): The session doing this look.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ look_string (str): A prepared look string, ready to send
+ off to any recipient (usually to ourselves)
+
+ """
+
+ iftargetandnotis_iter(target):
+ # single target - just show it
+ ifhasattr(target,"return_appearance"):
+ returntarget.return_appearance(self)
+ else:
+ return_("{target} has no in-game appearance.").format(target=target)
+ else:
+ # list of targets - make list to disconnect from db
+ characters=list(tarfortarintargetiftar)iftargetelse[]
+ sessions=self.sessions.all()
+ ifnotsessions:
+ # no sessions, nothing to report
+ return""
+ is_su=self.is_superuser
+
+ # text shown when looking in the ooc area
+ result=[f"Account |g{self.key}|n (you are Out-of-Character)"]
+
+ nsess=len(sessions)
+ result.append(
+ nsess==1
+ and"\n\n|wConnected session:|n"
+ orf"\n\n|wConnected sessions ({nsess}):|n"
+ )
+ forisess,sessinenumerate(sessions):
+ csessid=sess.sessid
+ addr="%s (%s)"%(
+ sess.protocol_key,
+ isinstance(sess.address,tuple)andstr(sess.address[0])orstr(sess.address),
+ )
+ result.append(
+ "\n%s%s"
+ %(
+ session
+ andsession.sessid==csessid
+ and"|w* %s|n"%(isess+1)
+ or" %s"%(isess+1),
+ addr,
+ )
+ )
+ result.append("\n\n |whelp|n - more commands")
+ result.append("\n |wpublic <Text>|n - talk on public channel")
+
+ charmax=_MAX_NR_CHARACTERS
+
+ ifis_suorlen(characters)<charmax:
+ ifnotcharacters:
+ result.append(
+ _(
+ "\n\n You don't have any characters yet. See |whelp @charcreate|n for creating one."
+ )
+ )
+ else:
+ result.append("\n |w@charcreate <name> [=description]|n - create new character")
+ result.append(
+ "\n |w@chardelete <name>|n - delete a character (cannot be undone!)"
+ )
+
+ ifcharacters:
+ string_s_ending=len(characters)>1and"s"or""
+ result.append("\n |w@ic <character>|n - enter the game (|w@ooc|n to get back here)")
+ ifis_su:
+ result.append(
+ f"\n\nAvailable character{string_s_ending} ({len(characters)}/unlimited):"
+ )
+ else:
+ result.append(
+ "\n\nAvailable character%s%s:"
+ %(
+ string_s_ending,
+ charmax>1and" (%i/%i)"%(len(characters),charmax)or"",
+ )
+ )
+
+ forcharincharacters:
+ csessions=char.sessions.all()
+ ifcsessions:
+ forsessincsessions:
+ # character is already puppeted
+ sid=sessinsessionsandsessions.index(sess)+1
+ ifsessandsid:
+ result.append(
+ f"\n - |G{char.key}|n [{', '.join(char.permissions.all())}] (played by you in session {sid})"
+ )
+ else:
+ result.append(
+ f"\n - |R{char.key}|n [{', '.join(char.permissions.all())}] (played by someone else)"
+ )
+ else:
+ # character is "free to puppet"
+ result.append(f"\n - {char.key} [{', '.join(char.permissions.all())}]")
+ look_string=("-"*68)+"\n"+"".join(result)+"\n"+("-"*68)
+ returnlook_string
+
+
+
[docs]classDefaultGuest(DefaultAccount):
+ """
+ This class is used for guest logins. Unlike Accounts, Guests and
+ their characters are deleted after disconnection.
+ """
+
+
[docs]@classmethod
+ defcreate(cls,**kwargs):
+ """
+ Forwards request to cls.authenticate(); returns a DefaultGuest object
+ if one is available for use.
+ """
+ returncls.authenticate(**kwargs)
+
+
[docs]@classmethod
+ defauthenticate(cls,**kwargs):
+ """
+ Gets or creates a Guest account object.
+
+ Keyword Args:
+ ip (str, optional): IP address of requestor; used for ban checking,
+ throttling and logging
+
+ Returns:
+ account (Object): Guest account object, if available
+ errors (list): List of error messages accrued during this request.
+
+ """
+ errors=[]
+ account=None
+ username=None
+ ip=kwargs.get("ip","").strip()
+
+ # check if guests are enabled.
+ ifnotsettings.GUEST_ENABLED:
+ errors.append(_("Guest accounts are not enabled on this server."))
+ returnNone,errors
+
+ try:
+ # Find an available guest name.
+ fornameinsettings.GUEST_LIST:
+ ifnotAccountDB.objects.filter(username__iexact=name).exists():
+ username=name
+ break
+ ifnotusername:
+ errors.append(_("All guest accounts are in use. Please try again later."))
+ ifip:
+ LOGIN_THROTTLE.update(ip,"Too many requests for Guest access.")
+ returnNone,errors
+ else:
+ # build a new account with the found guest username
+ password="%016x"%getrandbits(64)
+ home=settings.GUEST_HOME
+ permissions=settings.PERMISSION_GUEST_DEFAULT
+ typeclass=settings.BASE_GUEST_TYPECLASS
+
+ # Call parent class creator
+ account,errs=super(DefaultGuest,cls).create(
+ guest=True,
+ username=username,
+ password=password,
+ permissions=permissions,
+ typeclass=typeclass,
+ home=home,
+ ip=ip,
+ )
+ errors.extend(errs)
+
+ ifnotaccount.characters:
+ # this can happen for multisession_mode > 1. For guests we
+ # always auto-create a character, regardless of multi-session-mode.
+ character,errs=account.create_character()
+
+ iferrs:
+ errors.extend(errs)
+
+ returnaccount,errors
+
+ exceptExceptionase:
+ # We are in the middle between logged in and -not, so we have
+ # to handle tracebacks ourselves at this point. If we don't,
+ # we won't see any errors at all.
+ errors.append(_("An error occurred. Please e-mail an admin if the problem persists."))
+ logger.log_trace()
+ returnNone,errors
+
+ returnaccount,errors
+
+
[docs]defat_post_login(self,session=None,**kwargs):
+ """
+ In theory, guests only have one character regardless of which
+ MULTISESSION_MODE we're in. They don't get a choice.
+
+ Args:
+ session (Session, optional): Session connecting.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ self._send_to_connect_channel(_("|G{key} connected|n").format(key=self.key))
+ self.puppet_object(session,self.db._last_puppet)
+
+
[docs]defat_server_shutdown(self):
+ """
+ We repeat the functionality of `at_disconnect()` here just to
+ be on the safe side.
+ """
+ super().at_server_shutdown()
+ characters=self.db._playable_characters
+ forcharacterincharacters:
+ ifcharacter:
+ character.delete()
+
+
[docs]defat_post_disconnect(self,**kwargs):
+ """
+ Once having disconnected, destroy the guest's characters and
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ super().at_post_disconnect()
+ characters=self.db._playable_characters
+ forcharacterincharacters:
+ ifcharacter:
+ character.delete()
+ self.delete()
+
+ db_key=forms.RegexField(
+ label="Username",
+ initial="AccountDummy",
+ max_length=30,
+ regex=r"^[\w. @+-]+$",
+ required=False,
+ widget=forms.TextInput(attrs={"size":"30"}),
+ error_messages={
+ "invalid":"This value may contain only letters, spaces, numbers"
+ " and @/./+/-/_ characters."
+ },
+ help_text="This should be the same as the connected Account's key "
+ "name. 30 characters or fewer. Letters, spaces, digits and "
+ "@/./+/-/_ only.",
+ )
+
+ db_typeclass_path=forms.CharField(
+ label="Typeclass",
+ initial=settings.BASE_ACCOUNT_TYPECLASS,
+ widget=forms.TextInput(attrs={"size":"78"}),
+ help_text="Required. Defines what 'type' of entity this is. This "
+ "variable holds a Python path to a module with a valid "
+ "Evennia Typeclass. Defaults to "
+ "settings.BASE_ACCOUNT_TYPECLASS.",
+ )
+
+ db_permissions=forms.CharField(
+ label="Permissions",
+ initial=settings.PERMISSION_ACCOUNT_DEFAULT,
+ required=False,
+ widget=forms.TextInput(attrs={"size":"78"}),
+ help_text="In-game permissions. A comma-separated list of text "
+ "strings checked by certain locks. They are often used for "
+ "hierarchies, such as letting an Account have permission "
+ "'Admin', 'Builder' etc. An Account permission can be "
+ "overloaded by the permissions of a controlled Character. "
+ "Normal accounts use 'Accounts' by default.",
+ )
+
+ db_lock_storage=forms.CharField(
+ label="Locks",
+ widget=forms.Textarea(attrs={"cols":"100","rows":"2"}),
+ required=False,
+ help_text="In-game lock definition string. If not given, defaults "
+ "will be used. This string should be on the form "
+ "<i>type:lockfunction(args);type2:lockfunction2(args);...",
+ )
+ db_cmdset_storage=forms.CharField(
+ label="cmdset",
+ initial=settings.CMDSET_ACCOUNT,
+ widget=forms.TextInput(attrs={"size":"78"}),
+ required=False,
+ help_text="python path to account cmdset class (set in "
+ "settings.CMDSET_ACCOUNT by default)",
+ )
[docs]defsave_model(self,request,obj,form,change):
+ """
+ Custom save actions.
+
+ Args:
+ request (Request): Incoming request.
+ obj (Object): Object to save.
+ form (Form): Related form instance.
+ change (bool): False if this is a new save and not an update.
+
+ """
+ obj.save()
+ ifnotchange:
+ # calling hooks for new account
+ obj.set_class_from_typeclass(typeclass_path=settings.BASE_ACCOUNT_TYPECLASS)
+ obj.basetype_setup()
+ obj.at_account_creation()
+"""
+Bots are a special child typeclasses of
+Account that are controlled by the server.
+
+"""
+
+importtime
+fromdjango.confimportsettings
+fromevennia.accounts.accountsimportDefaultAccount
+fromevennia.scripts.scriptsimportDefaultScript
+fromevennia.utilsimportsearch
+fromevennia.utilsimportutils
+fromdjango.utils.translationimportgettextas_
+
+_IDLE_TIMEOUT=settings.IDLE_TIMEOUT
+
+_IRC_ENABLED=settings.IRC_ENABLED
+_RSS_ENABLED=settings.RSS_ENABLED
+_GRAPEVINE_ENABLED=settings.GRAPEVINE_ENABLED
+
+
+_SESSIONS=None
+
+
+# Bot helper utilities
+
+
+
[docs]classBotStarter(DefaultScript):
+ """
+ This non-repeating script has the
+ sole purpose of kicking its bot
+ into gear when it is initialized.
+
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Called once, when script is created.
+
+ """
+ self.key="botstarter"
+ self.desc="bot start/keepalive"
+ self.persistent=True
+ self.db.started=False
[docs]defat_repeat(self):
+ """
+ Called self.interval seconds to keep connection. We cannot use
+ the IDLE command from inside the game since the system will
+ not catch it (commands executed from the server side usually
+ has no sessions). So we update the idle counter manually here
+ instead. This keeps the bot getting hit by IDLE_TIMEOUT.
+
+ """
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+ forsessionin_SESSIONS.sessions_from_account(self.account):
+ session.update_session_counters(idle=True)
+
+
[docs]defat_server_reload(self):
+ """
+ If server reloads we don't need to reconnect the protocol
+ again, this is handled by the portal reconnect mechanism.
+
+ """
+ self.db.started=True
+
+
[docs]defat_server_shutdown(self):
+ """
+ Make sure we are shutdown.
+
+ """
+ self.db.started=False
+
+
+#
+# Bot base class
+
+
+
[docs]classBot(DefaultAccount):
+ """
+ A Bot will start itself when the server starts (it will generally
+ not do so on a reload - that will be handled by the normal Portal
+ session resync)
+
+ """
+
+
[docs]defbasetype_setup(self):
+ """
+ This sets up the basic properties for the bot.
+
+ """
+ # the text encoding to use.
+ self.db.encoding="utf-8"
+ # A basic security setup (also avoid idle disconnects)
+ lockstring=(
+ "examine:perm(Admin);edit:perm(Admin);delete:perm(Admin);"
+ "boot:perm(Admin);msg:false();noidletimeout:true()"
+ )
+ self.locks.add(lockstring)
+ # set the basics of being a bot
+ script_key=str(self.key)
+ self.scripts.add(BotStarter,key=script_key)
+ self.is_bot=True
+
+
[docs]defstart(self,**kwargs):
+ """
+ This starts the bot, whatever that may mean.
+
+ """
+ pass
[docs]defat_server_shutdown(self):
+ """
+ We need to handle this case manually since the shutdown may be
+ a reset.
+
+ """
+ forsessioninself.sessions.all():
+ session.sessionhandler.disconnect(session)
+
+
+# Bot implementations
+
+# IRC
+
+
+
[docs]classIRCBot(Bot):
+ """
+ Bot for handling IRC connections.
+
+ """
+
+ # override this on a child class to use custom factory
+ factory_path="evennia.server.portal.irc.IRCBotFactory"
+
+
[docs]defstart(
+ self,
+ ev_channel=None,
+ irc_botname=None,
+ irc_channel=None,
+ irc_network=None,
+ irc_port=None,
+ irc_ssl=None,
+ ):
+ """
+ Start by telling the portal to start a new session.
+
+ Args:
+ ev_channel (str): Key of the Evennia channel to connect to.
+ irc_botname (str): Name of bot to connect to irc channel. If
+ not set, use `self.key`.
+ irc_channel (str): Name of channel on the form `#channelname`.
+ irc_network (str): URL of the IRC network, like `irc.freenode.net`.
+ irc_port (str): Port number of the irc network, like `6667`.
+ irc_ssl (bool): Indicates whether to use SSL connection.
+
+ """
+ ifnot_IRC_ENABLED:
+ # the bot was created, then IRC was turned off. We delete
+ # ourselves (this will also kill the start script)
+ self.delete()
+ return
+
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+
+ # if keywords are given, store (the BotStarter script
+ # will not give any keywords, so this should normally only
+ # happen at initialization)
+ ifirc_botname:
+ self.db.irc_botname=irc_botname
+ elifnotself.db.irc_botname:
+ self.db.irc_botname=self.key
+ ifev_channel:
+ # connect to Evennia channel
+ channel=search.channel_search(ev_channel)
+ ifnotchannel:
+ raiseRuntimeError(f"Evennia Channel '{ev_channel}' not found.")
+ channel=channel[0]
+ channel.connect(self)
+ self.db.ev_channel=channel
+ ifirc_channel:
+ self.db.irc_channel=irc_channel
+ ifirc_network:
+ self.db.irc_network=irc_network
+ ifirc_port:
+ self.db.irc_port=irc_port
+ ifirc_ssl:
+ self.db.irc_ssl=irc_ssl
+
+ # instruct the server and portal to create a new session with
+ # the stored configuration
+ configdict={
+ "uid":self.dbid,
+ "botname":self.db.irc_botname,
+ "channel":self.db.irc_channel,
+ "network":self.db.irc_network,
+ "port":self.db.irc_port,
+ "ssl":self.db.irc_ssl,
+ }
+ _SESSIONS.start_bot_session(self.factory_path,configdict)
+
+
[docs]defat_msg_send(self,**kwargs):
+ "Shortcut here or we can end up in infinite loop"
+ pass
+
+
[docs]defget_nicklist(self,caller):
+ """
+ Retrive the nick list from the connected channel.
+
+ Args:
+ caller (Object or Account): The requester of the list. This will
+ be stored and echoed to when the irc network replies with the
+ requested info.
+
+ Notes: Since the return is asynchronous, the caller is stored internally
+ in a list; all callers in this list will get the nick info once it
+ returns (it is a custom OOB inputfunc option). The callback will not
+ survive a reload (which should be fine, it's very quick).
+ """
+ ifnothasattr(self,"_nicklist_callers"):
+ self._nicklist_callers=[]
+ self._nicklist_callers.append(caller)
+ super().msg(request_nicklist="")
+ return
+
+
[docs]defping(self,caller):
+ """
+ Fire a ping to the IRC server.
+
+ Args:
+ caller (Object or Account): The requester of the ping.
+
+ """
+ ifnothasattr(self,"_ping_callers"):
+ self._ping_callers=[]
+ self._ping_callers.append(caller)
+ super().msg(ping="")
+
+
[docs]defreconnect(self):
+ """
+ Force a protocol-side reconnect of the client without
+ having to destroy/recreate the bot "account".
+
+ """
+ super().msg(reconnect="")
+
+
[docs]defmsg(self,text=None,**kwargs):
+ """
+ Takes text from connected channel (only).
+
+ Args:
+ text (str, optional): Incoming text from channel.
+
+ Keyword Args:
+ options (dict): Options dict with the following allowed keys:
+ - from_channel (str): dbid of a channel this text originated from.
+ - from_obj (list): list of objects sending this text.
+
+ """
+ from_obj=kwargs.get("from_obj",None)
+ options=kwargs.get("options",None)or{}
+
+ ifnotself.ndb.ev_channelandself.db.ev_channel:
+ # cache channel lookup
+ self.ndb.ev_channel=self.db.ev_channel
+
+ if(
+ "from_channel"inoptions
+ andtext
+ andself.ndb.ev_channel.dbid==options["from_channel"]
+ ):
+ ifnotfrom_objorfrom_obj!=[self]:
+ super().msg(channel=text)
+
+
[docs]defexecute_cmd(self,session=None,txt=None,**kwargs):
+ """
+ Take incoming data and send it to connected channel. This is
+ triggered by the bot_data_in Inputfunc.
+
+ Args:
+ session (Session, optional): Session responsible for this
+ command. Note that this is the bot.
+ txt (str, optional): Command string.
+ Keyword Args:
+ user (str): The name of the user who sent the message.
+ channel (str): The name of channel the message was sent to.
+ type (str): Nature of message. Either 'msg', 'action', 'nicklist'
+ or 'ping'.
+ nicklist (list, optional): Set if `type='nicklist'`. This is a list
+ of nicks returned by calling the `self.get_nicklist`. It must look
+ for a list `self._nicklist_callers` which will contain all callers
+ waiting for the nicklist.
+ timings (float, optional): Set if `type='ping'`. This is the return
+ (in seconds) of a ping request triggered with `self.ping`. The
+ return must look for a list `self._ping_callers` which will contain
+ all callers waiting for the ping return.
+
+ """
+ ifkwargs["type"]=="nicklist":
+ # the return of a nicklist request
+ ifhasattr(self,"_nicklist_callers")andself._nicklist_callers:
+ chstr=f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})"
+ nicklist=", ".join(sorted(kwargs["nicklist"],key=lambdan:n.lower()))
+ forobjinself._nicklist_callers:
+ obj.msg(
+ _("Nicks at {chstr}:\n{nicklist}").format(chstr=chstr,nicklist=nicklist)
+ )
+ self._nicklist_callers=[]
+ return
+
+ elifkwargs["type"]=="ping":
+ # the return of a ping
+ ifhasattr(self,"_ping_callers")andself._ping_callers:
+ chstr=f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})"
+ forobjinself._ping_callers:
+ obj.msg(
+ _("IRC ping return from {chstr} took {time}s.").format(
+ chstr=chstr,time=kwargs["timing"]
+ )
+ )
+ self._ping_callers=[]
+ return
+
+ elifkwargs["type"]=="privmsg":
+ # A private message to the bot - a command.
+ user=kwargs["user"]
+
+ iftxt.lower().startswith("who"):
+ # return server WHO list (abbreviated for IRC)
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+ whos=[]
+ t0=time.time()
+ forsessin_SESSIONS.get_sessions():
+ delta_cmd=t0-sess.cmd_last_visible
+ delta_conn=t0-session.conn_time
+ account=sess.get_account()
+ whos.append(
+ "%s (%s/%s)"
+ %(
+ utils.crop("|w%s|n"%account.name,width=25),
+ utils.time_format(delta_conn,0),
+ utils.time_format(delta_cmd,1),
+ )
+ )
+ text=f"Who list (online/idle): {', '.join(sorted(whos,key=lambdaw:w.lower()))}"
+ eliftxt.lower().startswith("about"):
+ # some bot info
+ text=f"This is an Evennia IRC bot connecting from '{settings.SERVERNAME}'."
+ else:
+ text="I understand 'who' and 'about'."
+ super().msg(privmsg=((text,),{"user":user}))
+ else:
+ # something to send to the main channel
+ ifkwargs["type"]=="action":
+ # An action (irc pose)
+ text=f"{kwargs['user']}@{kwargs['channel']}{txt}"
+ else:
+ # msg - A normal channel message
+ text=f"{kwargs['user']}@{kwargs['channel']}: {txt}"
+
+ ifnotself.ndb.ev_channelandself.db.ev_channel:
+ # cache channel lookup
+ self.ndb.ev_channel=self.db.ev_channel
+
+ ifself.ndb.ev_channel:
+ self.ndb.ev_channel.msg(text,senders=self)
+
+
+#
+# RSS
+
+
+
[docs]classRSSBot(Bot):
+ """
+ An RSS relayer. The RSS protocol itself runs a ticker to update
+ its feed at regular intervals.
+
+ """
+
+
[docs]defstart(self,ev_channel=None,rss_url=None,rss_rate=None):
+ """
+ Start by telling the portal to start a new RSS session
+
+ Args:
+ ev_channel (str): Key of the Evennia channel to connect to.
+ rss_url (str): Full URL to the RSS feed to subscribe to.
+ rss_rate (int): How often for the feedreader to update.
+
+ Raises:
+ RuntimeError: If `ev_channel` does not exist.
+
+ """
+ ifnot_RSS_ENABLED:
+ # The bot was created, then RSS was turned off. Delete ourselves.
+ self.delete()
+ return
+
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+
+ ifev_channel:
+ # connect to Evennia channel
+ channel=search.channel_search(ev_channel)
+ ifnotchannel:
+ raiseRuntimeError(f"Evennia Channel '{ev_channel}' not found.")
+ channel=channel[0]
+ self.db.ev_channel=channel
+ ifrss_url:
+ self.db.rss_url=rss_url
+ ifrss_rate:
+ self.db.rss_rate=rss_rate
+ # instruct the server and portal to create a new session with
+ # the stored configuration
+ configdict={"uid":self.dbid,"url":self.db.rss_url,"rate":self.db.rss_rate}
+ _SESSIONS.start_bot_session("evennia.server.portal.rss.RSSBotFactory",configdict)
+
+
[docs]defexecute_cmd(self,txt=None,session=None,**kwargs):
+ """
+ Take incoming data and send it to connected channel. This is
+ triggered by the bot_data_in Inputfunc.
+
+ Args:
+ session (Session, optional): Session responsible for this
+ command.
+ txt (str, optional): Command string.
+ kwargs (dict, optional): Additional Information passed from bot.
+ Not used by the RSSbot by default.
+
+ """
+ ifnotself.ndb.ev_channelandself.db.ev_channel:
+ # cache channel lookup
+ self.ndb.ev_channel=self.db.ev_channel
+ ifself.ndb.ev_channel:
+ self.ndb.ev_channel.msg(txt,senders=self.id)
+
+
+# Grapevine bot
+
+
+
[docs]classGrapevineBot(Bot):
+ """
+ g Grapevine (https://grapevine.haus) relayer. The channel to connect to is the first
+ name in the settings.GRAPEVINE_CHANNELS list.
+
+ """
+
+ factory_path="evennia.server.portal.grapevine.RestartingWebsocketServerFactory"
+
+
[docs]defstart(self,ev_channel=None,grapevine_channel=None):
+ """
+ Start by telling the portal to connect to the grapevine network.
+
+ """
+ ifnot_GRAPEVINE_ENABLED:
+ self.delete()
+ return
+
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+
+ # connect to Evennia channel
+ ifev_channel:
+ # connect to Evennia channel
+ channel=search.channel_search(ev_channel)
+ ifnotchannel:
+ raiseRuntimeError(f"Evennia Channel '{ev_channel}' not found.")
+ channel=channel[0]
+ channel.connect(self)
+ self.db.ev_channel=channel
+
+ ifgrapevine_channel:
+ self.db.grapevine_channel=grapevine_channel
+
+ # these will be made available as properties on the protocol factory
+ configdict={"uid":self.dbid,"grapevine_channel":self.db.grapevine_channel}
+
+ _SESSIONS.start_bot_session(self.factory_path,configdict)
+
+
[docs]defat_msg_send(self,**kwargs):
+ "Shortcut here or we can end up in infinite loop"
+ pass
+
+
[docs]defmsg(self,text=None,**kwargs):
+ """
+ Takes text from connected channel (only).
+
+ Args:
+ text (str, optional): Incoming text from channel.
+
+ Keyword Args:
+ options (dict): Options dict with the following allowed keys:
+ - from_channel (str): dbid of a channel this text originated from.
+ - from_obj (list): list of objects sending this text.
+
+ """
+ from_obj=kwargs.get("from_obj",None)
+ options=kwargs.get("options",None)or{}
+
+ ifnotself.ndb.ev_channelandself.db.ev_channel:
+ # cache channel lookup
+ self.ndb.ev_channel=self.db.ev_channel
+
+ if(
+ "from_channel"inoptions
+ andtext
+ andself.ndb.ev_channel.dbid==options["from_channel"]
+ ):
+ ifnotfrom_objorfrom_obj!=[self]:
+ # send outputfunc channel(msg, chan, sender)
+
+ # TODO we should refactor channel formatting to operate on the
+ # account/object level instead. For now, remove the channel/name
+ # prefix since we pass that explicitly anyway
+ prefix,text=text.split(":",1)
+
+ super().msg(
+ channel=(
+ text.strip(),
+ self.db.grapevine_channel,
+ ", ".join(obj.keyforobjinfrom_obj),
+ {},
+ )
+ )
+
+
[docs]defexecute_cmd(
+ self,
+ txt=None,
+ session=None,
+ event=None,
+ grapevine_channel=None,
+ sender=None,
+ game=None,
+ **kwargs,
+ ):
+ """
+ Take incoming data from protocol and send it to connected channel. This is
+ triggered by the bot_data_in Inputfunc.
+ """
+ ifevent=="channels/broadcast":
+ # A private message to the bot - a command.
+
+ text=f"{sender}@{game}: {txt}"
+
+ ifnotself.ndb.ev_channelandself.db.ev_channel:
+ # simple cache of channel lookup
+ self.ndb.ev_channel=self.db.ev_channel
+ ifself.ndb.ev_channel:
+ self.ndb.ev_channel.msg(text,senders=self)
+"""
+The managers for the custom Account object and permissions.
+"""
+
+importdatetime
+fromdjango.utilsimporttimezone
+fromdjango.contrib.auth.modelsimportUserManager
+fromevennia.typeclasses.managersimportTypedObjectManager,TypeclassManager
+
+__all__=("AccountManager","AccountDBManager")
+
+
+#
+# Account Manager
+#
+
+
+
[docs]classAccountDBManager(TypedObjectManager,UserManager):
+ """
+ This AccountManager implements methods for searching
+ and manipulating Accounts directly from the database.
+
+ Evennia-specific search methods (will return Characters if
+ possible or a Typeclass/list of Typeclassed objects, whereas
+ Django-general methods will return Querysets or database objects):
+
+ dbref (converter)
+ dbref_search
+ get_dbref_range
+ object_totals
+ typeclass_search
+ num_total_accounts
+ get_connected_accounts
+ get_recently_created_accounts
+ get_recently_connected_accounts
+ get_account_from_email
+ get_account_from_uid
+ get_account_from_name
+ account_search (equivalent to evennia.search_account)
+
+ """
+
+
[docs]defnum_total_accounts(self):
+ """
+ Get total number of accounts.
+
+ Returns:
+ count (int): The total number of registered accounts.
+
+ """
+ returnself.count()
+
+
[docs]defget_connected_accounts(self):
+ """
+ Get all currently connected accounts.
+
+ Returns:
+ count (list): Account objects with currently
+ connected sessions.
+
+ """
+ returnself.filter(db_is_connected=True)
+
+
[docs]defget_recently_created_accounts(self,days=7):
+ """
+ Get accounts recently created.
+
+ Args:
+ days (int, optional): How many days in the past "recently" means.
+
+ Returns:
+ accounts (list): The Accounts created the last `days` interval.
+
+ """
+ end_date=timezone.now()
+ tdelta=datetime.timedelta(days)
+ start_date=end_date-tdelta
+ returnself.filter(date_joined__range=(start_date,end_date))
+
+
[docs]defget_recently_connected_accounts(self,days=7):
+ """
+ Get accounts recently connected to the game.
+
+ Args:
+ days (int, optional): Number of days backwards to check
+
+ Returns:
+ accounts (list): The Accounts connected to the game in the
+ last `days` interval.
+
+ """
+ end_date=timezone.now()
+ tdelta=datetime.timedelta(days)
+ start_date=end_date-tdelta
+ returnself.filter(last_login__range=(start_date,end_date)).order_by("-last_login")
+
+
[docs]defget_account_from_email(self,uemail):
+ """
+ Search account by
+ Returns an account object based on email address.
+
+ Args:
+ uemail (str): An email address to search for.
+
+ Returns:
+ account (Account): A found account, if found.
+
+ """
+ returnself.filter(email__iexact=uemail)
+
+
[docs]defget_account_from_uid(self,uid):
+ """
+ Get an account by id.
+
+ Args:
+ uid (int): Account database id.
+
+ Returns:
+ account (Account): The result.
+
+ """
+ try:
+ returnself.get(id=uid)
+ exceptself.model.DoesNotExist:
+ returnNone
+
+
[docs]defget_account_from_name(self,uname):
+ """
+ Get account object based on name.
+
+ Args:
+ uname (str): The Account name to search for.
+
+ Returns:
+ account (Account): The found account.
+
+ """
+ try:
+ returnself.get(username__iexact=uname)
+ exceptself.model.DoesNotExist:
+ returnNone
+
+
[docs]defsearch_account(self,ostring,exact=True,typeclass=None):
+ """
+ Searches for a particular account by name or
+ database id.
+
+ Args:
+ ostring (str or int): A key string or database id.
+ exact (bool, optional): Only valid for string matches. If
+ `True`, requires exact (non-case-sensitive) match,
+ otherwise also match also keys containing the `ostring`
+ (non-case-sensitive fuzzy match).
+ typeclass (str or Typeclass, optional): Limit the search only to
+ accounts of this typeclass.
+
+ """
+ dbref=self.dbref(ostring)
+ ifdbrefordbref==0:
+ # bref search is always exact
+ matches=self.filter(id=dbref)
+ ifmatches:
+ returnmatches
+ query={"username__iexact"ifexactelse"username__icontains":ostring}
+ iftypeclass:
+ # we accept both strings and actual typeclasses
+ ifcallable(typeclass):
+ typeclass=f"{typeclass.__module__}.{typeclass.__name__}"
+ else:
+ typeclass=str(typeclass)
+ query["db_typeclass_path"]=typeclass
+ ifexact:
+ matches=self.filter(**query)
+ else:
+ matches=self.filter(**query)
+ ifnotmatches:
+ # try alias match
+ matches=self.filter(
+ db_tags__db_tagtype__iexact="alias",
+ **{"db_tags__db_key__iexact"ifexactelse"db_tags__db_key__icontains":ostring},
+ )
+ returnmatches
+
+ # back-compatibility alias
+ account_search=search_account
+"""
+Account
+
+The account class is an extension of the default Django user class,
+and is customized for the needs of Evennia.
+
+We use the Account to store a more mud-friendly style of permission
+system as well as to allow the admin more flexibility by storing
+attributes on the Account. Within the game we should normally use the
+Account manager's methods to create users so that permissions are set
+correctly.
+
+To make the Account model more flexible for your own game, it can also
+persistently store attributes of its own. This is ideal for extra
+account info and OOC account configuration variables etc.
+
+"""
+fromdjango.confimportsettings
+fromdjango.dbimportmodels
+fromdjango.contrib.auth.modelsimportAbstractUser
+fromdjango.utils.encodingimportsmart_str
+
+fromevennia.accounts.managerimportAccountDBManager
+fromevennia.typeclasses.modelsimportTypedObject
+fromevennia.utils.utilsimportmake_iter
+fromevennia.server.signalsimportSIGNAL_ACCOUNT_POST_RENAME
+
+__all__=("AccountDB",)
+
+# _ME = _("me")
+# _SELF = _("self")
+
+_MULTISESSION_MODE=settings.MULTISESSION_MODE
+
+_GA=object.__getattribute__
+_SA=object.__setattr__
+_DA=object.__delattr__
+
+_TYPECLASS=None
+
+
+# ------------------------------------------------------------
+#
+# AccountDB
+#
+# ------------------------------------------------------------
+
+
+
[docs]classAccountDB(TypedObject,AbstractUser):
+ """
+ This is a special model using Django's 'profile' functionality
+ and extends the default Django User model. It is defined as such
+ by use of the variable AUTH_PROFILE_MODULE in the settings.
+ One accesses the fields/methods. We try use this model as much
+ as possible rather than User, since we can customize this to
+ our liking.
+
+ The TypedObject supplies the following (inherited) properties:
+
+ - key - main name
+ - typeclass_path - the path to the decorating typeclass
+ - typeclass - auto-linked typeclass
+ - date_created - time stamp of object creation
+ - permissions - perm strings
+ - dbref - #id of object
+ - db - persistent attribute storage
+ - ndb - non-persistent attribute storage
+
+ The AccountDB adds the following properties:
+
+ - is_connected - If any Session is currently connected to this Account
+ - name - alias for user.username
+ - sessions - sessions connected to this account
+ - is_superuser - bool if this account is a superuser
+ - is_bot - bool if this account is a bot and not a real account
+
+ """
+
+ #
+ # AccountDB Database model setup
+ #
+ # inherited fields (from TypedObject):
+ # db_key, db_typeclass_path, db_date_created, db_permissions
+
+ # store a connected flag here too, not just in sessionhandler.
+ # This makes it easier to track from various out-of-process locations
+ db_is_connected=models.BooleanField(
+ default=False,
+ verbose_name="is_connected",
+ help_text="If player is connected to game or not",
+ )
+ # database storage of persistant cmdsets.
+ db_cmdset_storage=models.CharField(
+ "cmdset",
+ max_length=255,
+ null=True,
+ help_text="optional python path to a cmdset class. If creating a Character, this will default to settings.CMDSET_CHARACTER.",
+ )
+ # marks if this is a "virtual" bot account object
+ db_is_bot=models.BooleanField(
+ default=False,verbose_name="is_bot",help_text="Used to identify irc/rss bots"
+ )
+
+ # Database manager
+ objects=AccountDBManager()
+
+ # defaults
+ __defaultclasspath__="evennia.accounts.accounts.DefaultAccount"
+ __applabel__="accounts"
+ __settingsclasspath__=settings.BASE_SCRIPT_TYPECLASS
+
+ # class Meta:
+ # verbose_name = "Account"
+
+ # cmdset_storage property
+ # This seems very sensitive to caching, so leaving it be for now /Griatch
+ # @property
+ def__cmdset_storage_get(self):
+ """
+ Getter. Allows for value = self.name. Returns a list of cmdset_storage.
+ """
+ storage=self.db_cmdset_storage
+ # we need to check so storage is not None
+ return[path.strip()forpathinstorage.split(",")]ifstorageelse[]
+
+ # @cmdset_storage.setter
+ def__cmdset_storage_set(self,value):
+ """
+ Setter. Allows for self.name = value. Stores as a comma-separated
+ string.
+ """
+ _SA(self,"db_cmdset_storage",",".join(str(val).strip()forvalinmake_iter(value)))
+ _GA(self,"save")()
+
+ # @cmdset_storage.deleter
+ def__cmdset_storage_del(self):
+ "Deleter. Allows for del self.name"
+ _SA(self,"db_cmdset_storage",None)
+ _GA(self,"save")()
+
+ cmdset_storage=property(__cmdset_storage_get,__cmdset_storage_set,__cmdset_storage_del)
+
+ #
+ # property/field access
+ #
+
+ def__str__(self):
+ returnsmart_str(f"{self.name}(account {self.dbid})")
+
+ def__repr__(self):
+ returnf"{self.name}(account#{self.dbid})"
+
+ # @property
+ def__username_get(self):
+ returnself.username
+
+ def__username_set(self,value):
+ old_name=self.username
+ self.username=value
+ self.save(update_fields=["username"])
+ SIGNAL_ACCOUNT_POST_RENAME.send(self,old_name=old_name,new_name=value)
+
+ def__username_del(self):
+ delself.username
+
+ # aliases
+ name=property(__username_get,__username_set,__username_del)
+ key=property(__username_get,__username_set,__username_del)
+
+ # @property
+ def__uid_get(self):
+ "Getter. Retrieves the user id"
+ returnself.id
+
+ def__uid_set(self,value):
+ raiseException("User id cannot be set!")
+
+ def__uid_del(self):
+ raiseException("User id cannot be deleted!")
+
+ uid=property(__uid_get,__uid_set,__uid_del)
+"""
+Command handler
+
+This module contains the infrastructure for accepting commands on the
+command line. The processing of a command works as follows:
+
+1. The calling object (caller) is analyzed based on its callertype.
+2. Cmdsets are gathered from different sources:
+ - channels: all available channel names are auto-created into a cmdset, to allow
+ for giving the channel name and have the following immediately
+ sent to the channel. The sending is performed by the CMD_CHANNEL
+ system command.
+ - object cmdsets: all objects at caller's location are scanned for non-empty
+ cmdsets. This includes cmdsets on exits.
+ - caller: the caller is searched for its own currently active cmdset.
+ - account: lastly the cmdsets defined on caller.account are added.
+3. The collected cmdsets are merged together to a combined, current cmdset.
+4. If the input string is empty -> check for CMD_NOINPUT command in
+ current cmdset or fallback to error message. Exit.
+5. The Command Parser is triggered, using the current cmdset to analyze the
+ input string for possible command matches.
+6. If multiple matches are found -> check for CMD_MULTIMATCH in current
+ cmdset, or fallback to error message. Exit.
+7. If no match was found -> check for CMD_NOMATCH in current cmdset or
+ fallback to error message. Exit.
+8. A single match was found. If this is a channel-command (i.e. the
+ ommand name is that of a channel), --> check for CMD_CHANNEL in
+ current cmdset or use channelhandler default. Exit.
+9. At this point we have found a normal command. We assign useful variables to it that
+ will be available to the command coder at run-time.
+12. We have a unique cmdobject, primed for use. Call all hooks:
+ `at_pre_cmd()`, `cmdobj.parse()`, `cmdobj.func()` and finally `at_post_cmd()`.
+13. Return deferred that will fire with the return from `cmdobj.func()` (unused by default).
+"""
+
+fromcollectionsimportdefaultdict
+fromweakrefimportWeakValueDictionary
+fromtracebackimportformat_exc
+fromitertoolsimportchain
+fromcopyimportcopy
+importtypes
+fromtwisted.internetimportreactor
+fromtwisted.internet.taskimportdeferLater
+fromtwisted.internet.deferimportinlineCallbacks,returnValue
+fromdjango.confimportsettings
+fromevennia.commands.commandimportInterruptCommand
+fromevennia.comms.channelhandlerimportCHANNELHANDLER
+fromevennia.utilsimportlogger,utils
+fromevennia.utils.utilsimportstring_suggestions
+
+fromdjango.utils.translationimportgettextas_
+
+_IN_GAME_ERRORS=settings.IN_GAME_ERRORS
+
+__all__=("cmdhandler","InterruptCommand")
+_GA=object.__getattribute__
+_CMDSET_MERGE_CACHE=WeakValueDictionary()
+
+# tracks recursive calls by each caller
+# to avoid infinite loops (commands calling themselves)
+_COMMAND_NESTING=defaultdict(lambda:0)
+_COMMAND_RECURSION_LIMIT=10
+
+# This decides which command parser is to be used.
+# You have to restart the server for changes to take effect.
+_COMMAND_PARSER=utils.variable_from_module(*settings.COMMAND_PARSER.rsplit(".",1))
+
+# System command names - import these variables rather than trying to
+# remember the actual string constants. If not defined, Evennia
+# hard-coded defaults are used instead.
+
+# command to call if user just presses <return> with no input
+CMD_NOINPUT="__noinput_command"
+# command to call if no command match was found
+CMD_NOMATCH="__nomatch_command"
+# command to call if multiple command matches were found
+CMD_MULTIMATCH="__multimatch_command"
+# command to call if found command is the name of a channel
+CMD_CHANNEL="__send_to_channel_command"
+# command to call as the very first one when the user connects.
+# (is expected to display the login screen)
+CMD_LOGINSTART="__unloggedin_look_command"
+
+# Function for handling multiple command matches.
+_SEARCH_AT_RESULT=utils.variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".",1))
+
+# Output strings. The first is the IN_GAME_ERRORS return, the second
+# is the normal "production message to echo to the account.
+
+_ERROR_UNTRAPPED=(
+ """
+An untrapped error occurred.
+""",
+ """
+An untrapped error occurred. Please file a bug report detailing the steps to reproduce.
+""",
+)
+
+_ERROR_CMDSETS=(
+ """
+A cmdset merger-error occurred. This is often due to a syntax
+error in one of the cmdsets to merge.
+""",
+ """
+A cmdset merger-error occurred. Please file a bug report detailing the
+steps to reproduce.
+""",
+)
+
+_ERROR_NOCMDSETS=(
+ """
+No command sets found! This is a critical bug that can have
+multiple causes.
+""",
+ """
+No command sets found! This is a sign of a critical bug. If
+disconnecting/reconnecting doesn't" solve the problem, try to contact
+the server admin through" some other means for assistance.
+""",
+)
+
+_ERROR_CMDHANDLER=(
+ """
+A command handler bug occurred. If this is not due to a local change,
+please file a bug report with the Evennia project, including the
+traceback and steps to reproduce.
+""",
+ """
+A command handler bug occurred. Please notify staff - they should
+likely file a bug report with the Evennia project.
+""",
+)
+
+_ERROR_RECURSION_LIMIT=(
+ "Command recursion limit ({recursion_limit}) ""reached for '{raw_cmdname}' ({cmdclass})."
+)
+
+
+# delayed imports
+_GET_INPUT=None
+
+
+# helper functions
+
+
+def_msg_err(receiver,stringtuple):
+ """
+ Helper function for returning an error to the caller.
+
+ Args:
+ receiver (Object): object to get the error message.
+ stringtuple (tuple): tuple with two strings - one for the
+ _IN_GAME_ERRORS mode (with the traceback) and one with the
+ production string (with a timestamp) to be shown to the user.
+
+ """
+ string="{traceback}\n{errmsg}\n(Traceback was logged {timestamp})."
+ timestamp=logger.timeformat()
+ tracestring=format_exc()
+ logger.log_trace()
+ if_IN_GAME_ERRORS:
+ receiver.msg(
+ string.format(
+ traceback=tracestring,errmsg=stringtuple[0].strip(),timestamp=timestamp
+ ).strip()
+ )
+ else:
+ receiver.msg(
+ string.format(
+ traceback=tracestring.splitlines()[-1],
+ errmsg=stringtuple[1].strip(),
+ timestamp=timestamp,
+ ).strip()
+ )
+
+
+def_process_input(caller,prompt,result,cmd,generator):
+ """
+ Specifically handle the get_input value to send to _progressive_cmd_run as
+ part of yielding from a Command's `func`.
+
+ Args:
+ caller (Character, Account or Session): the caller.
+ prompt (str): The sent prompt.
+ result (str): The unprocessed answer.
+ cmd (Command): The command itself.
+ generator (GeneratorType): The generator.
+
+ Returns:
+ result (bool): Always `False` (stop processing).
+
+ """
+ # We call it using a Twisted deferLater to make sure the input is properly closed.
+ deferLater(reactor,0,_progressive_cmd_run,cmd,generator,response=result)
+ returnFalse
+
+
+def_progressive_cmd_run(cmd,generator,response=None):
+ """
+ Progressively call the command that was given in argument. Used
+ when `yield` is present in the Command's `func()` method.
+
+ Args:
+ cmd (Command): the command itself.
+ generator (GeneratorType): the generator describing the processing.
+ reponse (str, optional): the response to send to the generator.
+
+ Raises:
+ ValueError: If the func call yields something not identifiable as a
+ time-delay or a string prompt.
+
+ Note:
+ This function is responsible for executing the command, if
+ the func() method contains 'yield' instructions. The yielded
+ value will be accessible at each step and will affect the
+ process. If the value is a number, just delay the execution
+ of the command. If it's a string, wait for the user input.
+
+ """
+ global_GET_INPUT
+ ifnot_GET_INPUT:
+ fromevennia.utils.evmenuimportget_inputas_GET_INPUT
+
+ try:
+ ifresponseisNone:
+ value=next(generator)
+ else:
+ value=generator.send(response)
+ exceptStopIteration:
+ # duplicated from cmdhandler._run_command, to have these
+ # run in the right order while staying inside the deferred
+ cmd.at_post_cmd()
+ ifcmd.save_for_next:
+ # store a reference to this command, possibly
+ # accessible by the next command.
+ cmd.caller.ndb.last_cmd=copy(cmd)
+ else:
+ cmd.caller.ndb.last_cmd=None
+ else:
+ ifisinstance(value,(int,float)):
+ utils.delay(value,_progressive_cmd_run,cmd,generator)
+ elifisinstance(value,str):
+ _GET_INPUT(cmd.caller,value,_process_input,cmd=cmd,generator=generator)
+ else:
+ raiseValueError("unknown type for a yielded value in command: {}".format(type(value)))
+
+
+# custom Exceptions
+
+
+classNoCmdSets(Exception):
+ "No cmdsets found. Critical error."
+ pass
+
+
+classExecSystemCommand(Exception):
+ "Run a system command"
+
+ def__init__(self,syscmd,sysarg):
+ self.args=(syscmd,sysarg)# needed by exception error handling
+ self.syscmd=syscmd
+ self.sysarg=sysarg
+
+
+classErrorReported(Exception):
+ "Re-raised when a subsructure already reported the error"
+
+ def__init__(self,raw_string):
+ self.args=(raw_string,)
+ self.raw_string=raw_string
+
+
+# Helper function
+
+
+@inlineCallbacks
+defget_and_merge_cmdsets(caller,session,account,obj,callertype,raw_string):
+ """
+ Gather all relevant cmdsets and merge them.
+
+ Args:
+ caller (Session, Account or Object): The entity executing the command. Which
+ type of object this is depends on the current game state; for example
+ when the user is not logged in, this will be a Session, when being OOC
+ it will be an Account and when puppeting an object this will (often) be
+ a Character Object. In the end it depends on where the cmdset is stored.
+ session (Session or None): The Session associated with caller, if any.
+ account (Account or None): The calling Account associated with caller, if any.
+ obj (Object or None): The Object associated with caller, if any.
+ callertype (str): This identifies caller as either "account", "object" or "session"
+ to avoid having to do this check internally.
+ raw_string (str): The input string. This is only used for error reporting.
+
+ Returns:
+ cmdset (Deferred): This deferred fires with the merged cmdset
+ result once merger finishes.
+
+ Notes:
+ The cdmsets are merged in order or generality, so that the
+ Object's cmdset is merged last (and will thus take precedence
+ over same-named and same-prio commands on Account and Session).
+
+ """
+ try:
+
+ @inlineCallbacks
+ def_get_channel_cmdset(account_or_obj):
+ """
+ Helper-method; Get channel-cmdsets
+ """
+ # Create cmdset for all account's available channels
+ try:
+ channel_cmdset=yieldCHANNELHANDLER.get_cmdset(account_or_obj)
+ returnValue([channel_cmdset])
+ exceptException:
+ _msg_err(caller,_ERROR_CMDSETS)
+ raiseErrorReported(raw_string)
+
+ @inlineCallbacks
+ def_get_local_obj_cmdsets(obj):
+ """
+ Helper-method; Get Object-level cmdsets
+ """
+ # Gather cmdsets from location, objects in location or carried
+ try:
+ local_obj_cmdsets=[None]
+ try:
+ location=obj.location
+ exceptException:
+ location=None
+ iflocation:
+ # Gather all cmdsets stored on objects in the room and
+ # also in the caller's inventory and the location itself
+ local_objlist=yield(
+ location.contents_get(exclude=obj)+obj.contents_get()+[location]
+ )
+ local_objlist=[oforoinlocal_objlistifnoto._is_deleted]
+ forlobjinlocal_objlist:
+ try:
+ # call hook in case we need to do dynamic changing to cmdset
+ _GA(lobj,"at_cmdset_get")(caller=caller)
+ exceptException:
+ logger.log_trace()
+ # the call-type lock is checked here, it makes sure an account
+ # is not seeing e.g. the commands on a fellow account (which is why
+ # the no_superuser_bypass must be True)
+ local_obj_cmdsets=yieldlist(
+ chain.from_iterable(
+ lobj.cmdset.cmdset_stack
+ forlobjinlocal_objlist
+ if(
+ lobj.cmdset.current
+ andlobj.access(
+ caller,access_type="call",no_superuser_bypass=True
+ )
+ )
+ )
+ )
+ forcsetinlocal_obj_cmdsets:
+ # This is necessary for object sets, or we won't be able to
+ # separate the command sets from each other in a busy room. We
+ # only keep the setting if duplicates were set to False/True
+ # explicitly.
+ cset.old_duplicates=cset.duplicates
+ cset.duplicates=Trueifcset.duplicatesisNoneelsecset.duplicates
+ returnValue(local_obj_cmdsets)
+ exceptException:
+ _msg_err(caller,_ERROR_CMDSETS)
+ raiseErrorReported(raw_string)
+
+ @inlineCallbacks
+ def_get_cmdsets(obj):
+ """
+ Helper method; Get cmdset while making sure to trigger all
+ hooks safely. Returns the stack and the valid options.
+ """
+ try:
+ yieldobj.at_cmdset_get()
+ exceptException:
+ _msg_err(caller,_ERROR_CMDSETS)
+ raiseErrorReported(raw_string)
+ try:
+ returnValue((obj.cmdset.current,list(obj.cmdset.cmdset_stack)))
+ exceptAttributeError:
+ returnValue(((None,None,None),[]))
+
+ local_obj_cmdsets=[]
+ ifcallertype=="session":
+ # we are calling the command from the session level
+ report_to=session
+ current,cmdsets=yield_get_cmdsets(session)
+ ifaccount:# this automatically implies logged-in
+ pcurrent,account_cmdsets=yield_get_cmdsets(account)
+ cmdsets+=account_cmdsets
+ current=current+pcurrent
+ ifobj:
+ ocurrent,obj_cmdsets=yield_get_cmdsets(obj)
+ current=current+ocurrent
+ cmdsets+=obj_cmdsets
+ ifnotcurrent.no_objs:
+ local_obj_cmdsets=yield_get_local_obj_cmdsets(obj)
+ ifcurrent.no_exits:
+ # filter out all exits
+ local_obj_cmdsets=[
+ cmdsetforcmdsetinlocal_obj_cmdsetsifcmdset.key!="ExitCmdSet"
+ ]
+ cmdsets+=local_obj_cmdsets
+ ifnotcurrent.no_channels:
+ # also objs may have channels
+ channel_cmdsets=yield_get_channel_cmdset(obj)
+ cmdsets+=channel_cmdsets
+ ifnotcurrent.no_channels:
+ channel_cmdsets=yield_get_channel_cmdset(account)
+ cmdsets+=channel_cmdsets
+
+ elifcallertype=="account":
+ # we are calling the command from the account level
+ report_to=account
+ current,cmdsets=yield_get_cmdsets(account)
+ ifobj:
+ ocurrent,obj_cmdsets=yield_get_cmdsets(obj)
+ current=current+ocurrent
+ cmdsets+=obj_cmdsets
+ ifnotcurrent.no_objs:
+ local_obj_cmdsets=yield_get_local_obj_cmdsets(obj)
+ ifcurrent.no_exits:
+ # filter out all exits
+ local_obj_cmdsets=[
+ cmdsetforcmdsetinlocal_obj_cmdsetsifcmdset.key!="ExitCmdSet"
+ ]
+ cmdsets+=local_obj_cmdsets
+ ifnotcurrent.no_channels:
+ # also objs may have channels
+ cmdsets+=yield_get_channel_cmdset(obj)
+ ifnotcurrent.no_channels:
+ cmdsets+=yield_get_channel_cmdset(account)
+
+ elifcallertype=="object":
+ # we are calling the command from the object level
+ report_to=obj
+ current,cmdsets=yield_get_cmdsets(obj)
+ ifnotcurrent.no_objs:
+ local_obj_cmdsets=yield_get_local_obj_cmdsets(obj)
+ ifcurrent.no_exits:
+ # filter out all exits
+ local_obj_cmdsets=[
+ cmdsetforcmdsetinlocal_obj_cmdsetsifcmdset.key!="ExitCmdSet"
+ ]
+ cmdsets+=yieldlocal_obj_cmdsets
+ ifnotcurrent.no_channels:
+ # also objs may have channels
+ cmdsets+=yield_get_channel_cmdset(obj)
+ else:
+ raiseException("get_and_merge_cmdsets: callertype %s is not valid."%callertype)
+
+ # weed out all non-found sets
+ cmdsets=yield[cmdsetforcmdsetincmdsetsifcmdsetandcmdset.key!="_EMPTY_CMDSET"]
+ # report cmdset errors to user (these should already have been logged)
+ yield[
+ report_to.msg(cmdset.errmessage)forcmdsetincmdsetsifcmdset.key=="_CMDSET_ERROR"
+ ]
+
+ ifcmdsets:
+ # faster to do tuple on list than to build tuple directly
+ mergehash=tuple([id(cmdset)forcmdsetincmdsets])
+ ifmergehashin_CMDSET_MERGE_CACHE:
+ # cached merge exist; use that
+ cmdset=_CMDSET_MERGE_CACHE[mergehash]
+ else:
+ # we group and merge all same-prio cmdsets separately (this avoids
+ # order-dependent clashes in certain cases, such as
+ # when duplicates=True)
+ tempmergers={}
+ forcmdsetincmdsets:
+ prio=cmdset.priority
+ ifpriointempmergers:
+ # merge same-prio cmdset together separately
+ tempmergers[prio]=yieldtempmergers[prio]+cmdset
+ else:
+ tempmergers[prio]=cmdset
+
+ # sort cmdsets after reverse priority (highest prio are merged in last)
+ sorted_cmdsets=yieldsorted(list(tempmergers.values()),key=lambdax:x.priority)
+
+ # Merge all command sets into one, beginning with the lowest-prio one
+ cmdset=sorted_cmdsets[0]
+ formerging_cmdsetinsorted_cmdsets[1:]:
+ cmdset=yieldcmdset+merging_cmdset
+ # store the original, ungrouped set for diagnosis
+ cmdset.merged_from=cmdsets
+ # cache
+ _CMDSET_MERGE_CACHE[mergehash]=cmdset
+ else:
+ cmdset=None
+ forcsetin(csetforcsetinlocal_obj_cmdsetsifcset):
+ cset.duplicates=cset.old_duplicates
+ # important - this syncs the CmdSetHandler's .current field with the
+ # true current cmdset!
+ ifcmdset:
+ caller.cmdset.current=cmdset
+
+ returnValue(cmdset)
+ exceptErrorReported:
+ raise
+ exceptException:
+ _msg_err(caller,_ERROR_CMDSETS)
+ raise
+ # raise ErrorReported
+
+
+# Main command-handler function
+
+
+
[docs]@inlineCallbacks
+defcmdhandler(
+ called_by,
+ raw_string,
+ _testing=False,
+ callertype="session",
+ session=None,
+ cmdobj=None,
+ cmdobj_key=None,
+ **kwargs,
+):
+ """
+ This is the main mechanism that handles any string sent to the engine.
+
+ Args:
+ called_by (Session, Account or Object): Object from which this
+ command was called. which this was called from. What this is
+ depends on the game state.
+ raw_string (str): The command string as given on the command line.
+ _testing (bool, optional): Used for debug purposes and decides if we
+ should actually execute the command or not. If True, the
+ command instance will be returned.
+ callertype (str, optional): One of "session", "account" or
+ "object". These are treated in decending order, so when the
+ Session is the caller, it will merge its own cmdset into
+ cmdsets from both Account and eventual puppeted Object (and
+ cmdsets in its room etc). An Account will only include its own
+ cmdset and the Objects and so on. Merge order is the same
+ order, so that Object cmdsets are merged in last, giving them
+ precendence for same-name and same-prio commands.
+ session (Session, optional): Relevant if callertype is "account" - the session will help
+ retrieve the correct cmdsets from puppeted objects.
+ cmdobj (Command, optional): If given a command instance, this will be executed using
+ `called_by` as the caller, `raw_string` representing its arguments and (optionally)
+ `cmdobj_key` as its input command name. No cmdset lookup will be performed but
+ all other options apply as normal. This allows for running a specific Command
+ within the command system mechanism.
+ cmdobj_key (string, optional): Used together with `cmdobj` keyword to specify
+ which cmdname should be assigned when calling the specified Command instance. This
+ is made available as `self.cmdstring` when the Command runs.
+ If not given, the command will be assumed to be called as `cmdobj.key`.
+
+ Keyword Args:
+ kwargs (any): other keyword arguments will be assigned as named variables on the
+ retrieved command object *before* it is executed. This is unused
+ in default Evennia but may be used by code to set custom flags or
+ special operating conditions for a command as it executes.
+
+ Returns:
+ deferred (Deferred): This deferred is fired with the return
+ value of the command's `func` method. This is not used in
+ default Evennia.
+
+ """
+
+ @inlineCallbacks
+ def_run_command(cmd,cmdname,args,raw_cmdname,cmdset,session,account):
+ """
+ Helper function: This initializes and runs the Command
+ instance once the parser has identified it as either a normal
+ command or one of the system commands.
+
+ Args:
+ cmd (Command): Command object
+ cmdname (str): Name of command
+ args (str): extra text entered after the identified command
+ raw_cmdname (str): Name of Command, unaffected by eventual
+ prefix-stripping (if no prefix-stripping, this is the same
+ as cmdname).
+ cmdset (CmdSet): Command sert the command belongs to (if any)..
+ session (Session): Session of caller (if any).
+ account (Account): Account of caller (if any).
+
+ Returns:
+ deferred (Deferred): this will fire with the return of the
+ command's `func` method.
+
+ Raises:
+ RuntimeError: If command recursion limit was reached.
+
+ """
+ global_COMMAND_NESTING
+ try:
+ # Assign useful variables to the instance
+ cmd.caller=caller
+ cmd.cmdname=cmdname
+ cmd.raw_cmdname=raw_cmdname
+ cmd.cmdstring=cmdname# deprecated
+ cmd.args=args
+ cmd.cmdset=cmdset
+ cmd.session=session
+ cmd.account=account
+ cmd.raw_string=unformatted_raw_string
+ # cmd.obj # set via on-object cmdset handler for each command,
+ # since this may be different for every command when
+ # merging multiple cmdsets
+
+ ifhasattr(cmd,"obj")andhasattr(cmd.obj,"scripts"):
+ # cmd.obj is automatically made available by the cmdhandler.
+ # we make sure to validate its scripts.
+ yieldcmd.obj.scripts.validate()
+
+ if_testing:
+ # only return the command instance
+ returnValue(cmd)
+
+ # assign custom kwargs to found cmd object
+ forkey,valinkwargs.items():
+ setattr(cmd,key,val)
+
+ _COMMAND_NESTING[called_by]+=1
+ if_COMMAND_NESTING[called_by]>_COMMAND_RECURSION_LIMIT:
+ err=_ERROR_RECURSION_LIMIT.format(
+ recursion_limit=_COMMAND_RECURSION_LIMIT,
+ raw_cmdname=raw_cmdname,
+ cmdclass=cmd.__class__,
+ )
+ raiseRuntimeError(err)
+
+ # pre-command hook
+ abort=yieldcmd.at_pre_cmd()
+ ifabort:
+ # abort sequence
+ returnValue(abort)
+
+ # Parse and execute
+ yieldcmd.parse()
+
+ # main command code
+ # (return value is normally None)
+ ret=cmd.func()
+ ifisinstance(ret,types.GeneratorType):
+ # cmd.func() is a generator, execute progressively
+ _progressive_cmd_run(cmd,ret)
+ ret=yieldret
+ # note that the _progressive_cmd_run will itself run
+ # the at_post_cmd etc as it finishes; this is a bit of
+ # code duplication but there seems to be no way to
+ # catch the StopIteration here (it's not in the same
+ # frame since this is in a deferred chain)
+ else:
+ ret=yieldret
+ # post-command hook
+ yieldcmd.at_post_cmd()
+
+ ifcmd.save_for_next:
+ # store a reference to this command, possibly
+ # accessible by the next command.
+ caller.ndb.last_cmd=yieldcopy(cmd)
+ else:
+ caller.ndb.last_cmd=None
+
+ # return result to the deferred
+ returnValue(ret)
+
+ exceptInterruptCommand:
+ # Do nothing, clean exit
+ pass
+ exceptException:
+ _msg_err(caller,_ERROR_UNTRAPPED)
+ raiseErrorReported(raw_string)
+ finally:
+ _COMMAND_NESTING[called_by]-=1
+
+ session,account,obj=session,None,None
+ ifcallertype=="session":
+ session=called_by
+ account=session.account
+ obj=session.puppet
+ elifcallertype=="account":
+ account=called_by
+ ifsession:
+ obj=yieldsession.puppet
+ elifcallertype=="object":
+ obj=called_by
+ else:
+ raiseRuntimeError("cmdhandler: callertype %s is not valid."%callertype)
+ # the caller will be the one to receive messages and excert its permissions.
+ # we assign the caller with preference 'bottom up'
+ caller=objoraccountorsession
+ # The error_to is the default recipient for errors. Tries to make sure an account
+ # does not get spammed for errors while preserving character mirroring.
+ error_to=objorsessionoraccount
+
+ try:# catch bugs in cmdhandler itself
+ try:# catch special-type commands
+ ifcmdobj:
+ # the command object is already given
+ cmd=cmdobj()ifcallable(cmdobj)elsecmdobj
+ cmdname=cmdobj_keyifcmdobj_keyelsecmd.key
+ args=raw_string
+ unformatted_raw_string="%s%s"%(cmdname,args)
+ cmdset=None
+ raw_cmdname=cmdname
+ # session = session
+ # account = account
+
+ else:
+ # no explicit cmdobject given, figure it out
+ cmdset=yieldget_and_merge_cmdsets(
+ caller,session,account,obj,callertype,raw_string
+ )
+ ifnotcmdset:
+ # this is bad and shouldn't happen.
+ raiseNoCmdSets
+ # store the completely unmodified raw string - including
+ # whitespace and eventual prefixes-to-be-stripped.
+ unformatted_raw_string=raw_string
+ raw_string=raw_string.strip()
+ ifnotraw_string:
+ # Empty input. Test for system command instead.
+ syscmd=yieldcmdset.get(CMD_NOINPUT)
+ sysarg=""
+ raiseExecSystemCommand(syscmd,sysarg)
+ # Parse the input string and match to available cmdset.
+ # This also checks for permissions, so all commands in match
+ # are commands the caller is allowed to call.
+ matches=yield_COMMAND_PARSER(raw_string,cmdset,caller)
+
+ # Deal with matches
+
+ iflen(matches)>1:
+ # We have a multiple-match
+ syscmd=yieldcmdset.get(CMD_MULTIMATCH)
+ sysarg=_("There were multiple matches.")
+ ifsyscmd:
+ # use custom CMD_MULTIMATCH
+ syscmd.matches=matches
+ else:
+ # fall back to default error handling
+ sysarg=yield_SEARCH_AT_RESULT(
+ [match[2]formatchinmatches],caller,query=matches[0][0]
+ )
+ raiseExecSystemCommand(syscmd,sysarg)
+
+ cmdname,args,cmd,raw_cmdname="","",None,""
+ iflen(matches)==1:
+ # We have a unique command match. But it may still be invalid.
+ match=matches[0]
+ cmdname,args,cmd,raw_cmdname=(match[0],match[1],match[2],match[5])
+
+ ifnotmatches:
+ # No commands match our entered command
+ syscmd=yieldcmdset.get(CMD_NOMATCH)
+ ifsyscmd:
+ # use custom CMD_NOMATCH command
+ sysarg=raw_string
+ else:
+ # fallback to default error text
+ sysarg=_("Command '{command}' is not available.").format(
+ command=raw_string
+ )
+ suggestions=string_suggestions(
+ raw_string,
+ cmdset.get_all_cmd_keys_and_aliases(caller),
+ cutoff=0.7,
+ maxnum=3,
+ )
+ ifsuggestions:
+ sysarg+=_(" Maybe you meant {command}?").format(
+ command=utils.list_to_string(suggestions,_("or"),addquote=True)
+ )
+ else:
+ sysarg+=_(' Type "help" for help.')
+ raiseExecSystemCommand(syscmd,sysarg)
+
+ # Check if this is a Channel-cmd match.
+ ifhasattr(cmd,"is_channel")andcmd.is_channel:
+ # even if a user-defined syscmd is not defined, the
+ # found cmd is already a system command in its own right.
+ syscmd=yieldcmdset.get(CMD_CHANNEL)
+ ifsyscmd:
+ # replace system command with custom version
+ cmd=syscmd
+ cmd.session=session
+ sysarg="%s:%s"%(cmdname,args)
+ raiseExecSystemCommand(cmd,sysarg)
+
+ # A normal command.
+ ret=yield_run_command(cmd,cmdname,args,raw_cmdname,cmdset,session,account)
+ returnValue(ret)
+
+ exceptErrorReportedasexc:
+ # this error was already reported, so we
+ # catch it here and don't pass it on.
+ logger.log_err("User input was: '%s'."%exc.raw_string)
+
+ exceptExecSystemCommandasexc:
+ # Not a normal command: run a system command, if available,
+ # or fall back to a return string.
+ syscmd=exc.syscmd
+ sysarg=exc.sysarg
+
+ ifsyscmd:
+ ret=yield_run_command(
+ syscmd,syscmd.key,sysarg,unformatted_raw_string,cmdset,session,account
+ )
+ returnValue(ret)
+ elifsysarg:
+ # return system arg
+ error_to.msg(exc.sysarg)
+
+ exceptNoCmdSets:
+ # Critical error.
+ logger.log_err("No cmdsets found: %s"%caller)
+ error_to.msg(_ERROR_NOCMDSETS)
+
+ exceptException:
+ # We should not end up here. If we do, it's a programming bug.
+ _msg_err(error_to,_ERROR_UNTRAPPED)
+
+ exceptException:
+ # This catches exceptions in cmdhandler exceptions themselves
+ _msg_err(error_to,_ERROR_CMDHANDLER)
+"""
+The default command parser. Use your own by assigning
+`settings.COMMAND_PARSER` to a Python path to a module containing the
+replacing cmdparser function. The replacement parser must accept the
+same inputs as the default one.
+
+"""
+
+
+importre
+fromdjango.confimportsettings
+fromevennia.utils.loggerimportlog_trace
+
+_MULTIMATCH_REGEX=re.compile(settings.SEARCH_MULTIMATCH_REGEX,re.I+re.U)
+_CMD_IGNORE_PREFIXES=settings.CMD_IGNORE_PREFIXES
+
+
+
[docs]defcreate_match(cmdname,string,cmdobj,raw_cmdname):
+ """
+ Builds a command match by splitting the incoming string and
+ evaluating the quality of the match.
+
+ Args:
+ cmdname (str): Name of command to check for.
+ string (str): The string to match against.
+ cmdobj (str): The full Command instance.
+ raw_cmdname (str, optional): If CMD_IGNORE_PREFIX is set and the cmdname starts with
+ one of the prefixes to ignore, this contains the raw, unstripped cmdname,
+ otherwise it is None.
+
+ Returns:
+ match (tuple): This is on the form (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname),
+ where `cmdname` is the command's name and `args` is the rest of the incoming
+ string, without said command name. `cmdobj` is
+ the Command instance, the cmdlen is the same as len(cmdname) and mratio
+ is a measure of how big a part of the full input string the cmdname
+ takes up - an exact match would be 1.0. Finally, the `raw_cmdname` is
+ the cmdname unmodified by eventual prefix-stripping.
+
+ """
+ cmdlen,strlen=len(str(cmdname)),len(str(string))
+ mratio=1-(strlen-cmdlen)/(1.0*strlen)
+ args=string[cmdlen:]
+ return(cmdname,args,cmdobj,cmdlen,mratio,raw_cmdname)
+
+
+
[docs]defbuild_matches(raw_string,cmdset,include_prefixes=False):
+ """
+ Build match tuples by matching raw_string against available commands.
+
+ Args:
+ raw_string (str): Input string that can look in any way; the only assumption is
+ that the sought command's name/alias must be *first* in the string.
+ cmdset (CmdSet): The current cmdset to pick Commands from.
+ include_prefixes (bool): If set, include prefixes like @, ! etc (specified in settings)
+ in the match, otherwise strip them before matching.
+
+ Returns:
+ matches (list) A list of match tuples created by `cmdparser.create_match`.
+
+ """
+ matches=[]
+ try:
+ ifinclude_prefixes:
+ # use the cmdname as-is
+ l_raw_string=raw_string.lower()
+ forcmdincmdset:
+ matches.extend(
+ [
+ create_match(cmdname,raw_string,cmd,cmdname)
+ forcmdnamein[cmd.key]+cmd.aliases
+ ifcmdname
+ andl_raw_string.startswith(cmdname.lower())
+ and(notcmd.arg_regexorcmd.arg_regex.match(l_raw_string[len(cmdname):]))
+ ]
+ )
+ else:
+ # strip prefixes set in settings
+ raw_string=(
+ raw_string.lstrip(_CMD_IGNORE_PREFIXES)iflen(raw_string)>1elseraw_string
+ )
+ l_raw_string=raw_string.lower()
+ forcmdincmdset:
+ forraw_cmdnamein[cmd.key]+cmd.aliases:
+ cmdname=(
+ raw_cmdname.lstrip(_CMD_IGNORE_PREFIXES)
+ iflen(raw_cmdname)>1
+ elseraw_cmdname
+ )
+ if(
+ cmdname
+ andl_raw_string.startswith(cmdname.lower())
+ and(notcmd.arg_regexorcmd.arg_regex.match(l_raw_string[len(cmdname):]))
+ ):
+ matches.append(create_match(cmdname,raw_string,cmd,raw_cmdname))
+ exceptException:
+ log_trace("cmdhandler error. raw_input:%s"%raw_string)
+ returnmatches
+
+
+
[docs]deftry_num_prefixes(raw_string):
+ """
+ Test if user tried to separate multi-matches with a number separator
+ (default 1-name, 2-name etc). This is usually called last, if no other
+ match was found.
+
+ Args:
+ raw_string (str): The user input to parse.
+
+ Returns:
+ mindex, new_raw_string (tuple): If a multimatch-separator was detected,
+ this is stripped out as an integer to separate between the matches. The
+ new_raw_string is the result of stripping out that identifier. If no
+ such form was found, returns (None, None).
+
+ Example:
+ In the default configuration, entering 2-ball (e.g. in a room will more
+ than one 'ball' object), will lead to a multimatch and this function
+ will parse `"2-ball"` and return `(2, "ball")`.
+
+ """
+ # no matches found
+ num_ref_match=_MULTIMATCH_REGEX.match(raw_string)
+ ifnum_ref_match:
+ # the user might be trying to identify the command
+ # with a #num-command style syntax. We expect the regex to
+ # contain the groups "number" and "name".
+ mindex,new_raw_string=(num_ref_match.group("number"),num_ref_match.group("name"))
+ returnmindex,new_raw_string
+ else:
+ returnNone,None
+
+
+
[docs]defcmdparser(raw_string,cmdset,caller,match_index=None):
+ """
+ This function is called by the cmdhandler once it has
+ gathered and merged all valid cmdsets valid for this particular parsing.
+
+ Args:
+ raw_string (str): The unparsed text entered by the caller.
+ cmdset (CmdSet): The merged, currently valid cmdset
+ caller (Session, Account or Object): The caller triggering this parsing.
+ match_index (int, optional): Index to pick a given match in a
+ list of same-named command matches. If this is given, it suggests
+ this is not the first time this function was called: normally
+ the first run resulted in a multimatch, and the index is given
+ to select between the results for the second run.
+
+ Returns:
+ matches (list): This is a list of match-tuples as returned by `create_match`.
+ If no matches were found, this is an empty list.
+
+ Notes:
+ The cmdparser understand the following command combinations (where
+ [] marks optional parts.
+
+ ```
+ [cmdname[ cmdname2 cmdname3 ...] [the rest]
+ ```
+
+ A command may consist of any number of space-separated words of any
+ length, and contain any character. It may also be empty.
+
+ The parser makes use of the cmdset to find command candidates. The
+ parser return a list of matches. Each match is a tuple with its
+ first three elements being the parsed cmdname (lower case),
+ the remaining arguments, and the matched cmdobject from the cmdset.
+
+ """
+ ifnotraw_string:
+ return[]
+
+ # find mathces, first using the full name
+ matches=build_matches(raw_string,cmdset,include_prefixes=True)
+ ifnotmatches:
+ # try to match a number 1-cmdname, 2-cmdname etc
+ mindex,new_raw_string=try_num_prefixes(raw_string)
+ ifmindexisnotNone:
+ returncmdparser(new_raw_string,cmdset,caller,match_index=int(mindex))
+ if_CMD_IGNORE_PREFIXES:
+ # still no match. Try to strip prefixes
+ raw_string=(
+ raw_string.lstrip(_CMD_IGNORE_PREFIXES)iflen(raw_string)>1elseraw_string
+ )
+ matches=build_matches(raw_string,cmdset,include_prefixes=False)
+
+ # only select command matches we are actually allowed to call.
+ matches=[matchformatchinmatchesifmatch[2].access(caller,"cmd")]
+
+ # try to bring the number of matches down to 1
+ iflen(matches)>1:
+ # See if it helps to analyze the match with preserved case but only if
+ # it leaves at least one match.
+ trimmed=[matchformatchinmatchesifraw_string.startswith(match[0])]
+ iftrimmed:
+ matches=trimmed
+
+ iflen(matches)>1:
+ # we still have multiple matches. Sort them by count quality.
+ matches=sorted(matches,key=lambdam:m[3])
+ # only pick the matches with highest count quality
+ quality=[mat[3]formatinmatches]
+ matches=matches[-quality.count(quality[-1]):]
+
+ iflen(matches)>1:
+ # still multiple matches. Fall back to ratio-based quality.
+ matches=sorted(matches,key=lambdam:m[4])
+ # only pick the highest rated ratio match
+ quality=[mat[4]formatinmatches]
+ matches=matches[-quality.count(quality[-1]):]
+
+ iflen(matches)>1andmatch_indexisnotNone:
+ # We couldn't separate match by quality, but we have an
+ # index argument to tell us which match to use.
+ if0<match_index<=len(matches):
+ matches=[matches[match_index-1]]
+ else:
+ # we tried to give an index outside of the range - this means
+ # a no-match
+ matches=[]
+
+ # no matter what we have at this point, we have to return it.
+ returnmatches
+"""
+
+A Command Set (CmdSet) holds a set of commands. The Cmdsets can be
+merged and combined to create new sets of commands in a
+non-destructive way. This makes them very powerful for implementing
+custom game states where different commands (or different variations
+of commands) are available to the accounts depending on circumstance.
+
+The available merge operations are partly borrowed from mathematical
+Set theory.
+
+
+* Union The two command sets are merged so that as many commands as
+ possible of each cmdset ends up in the merged cmdset. Same-name
+ commands are merged by priority. This is the most common default.
+ Ex: A1,A3 + B1,B2,B4,B5 = A1,B2,A3,B4,B5
+* Intersect - Only commands found in *both* cmdsets (i.e. which have
+ same names) end up in the merged cmdset, with the higher-priority
+ cmdset replacing the lower one. Ex: A1,A3 + B1,B2,B4,B5 = A1
+* Replace - The commands of this cmdset completely replaces the
+ lower-priority cmdset's commands, regardless of if same-name commands
+ exist. Ex: A1,A3 + B1,B2,B4,B5 = A1,A3
+* Remove - This removes the relevant commands from the
+ lower-priority cmdset completely. They are not replaced with
+ anything, so this in effects uses the high-priority cmdset as a filter
+ to affect the low-priority cmdset. Ex: A1,A3 + B1,B2,B4,B5 = B2,B4,B5
+
+"""
+fromweakrefimportWeakKeyDictionary
+fromdjango.utils.translationimportgettextas_
+fromevennia.utils.utilsimportinherits_from,is_iter
+
+__all__=("CmdSet",)
+
+
+class_CmdSetMeta(type):
+ """
+ This metaclass makes some minor on-the-fly convenience fixes to
+ the cmdset class.
+
+ """
+
+ def__init__(cls,*args,**kwargs):
+ """
+ Fixes some things in the cmdclass
+
+ """
+ # by default we key the cmdset the same as the
+ # name of its class.
+ ifnothasattr(cls,"key")ornotcls.key:
+ cls.key=cls.__name__
+ cls.path="%s.%s"%(cls.__module__,cls.__name__)
+
+ ifnotisinstance(cls.key_mergetypes,dict):
+ cls.key_mergetypes={}
+
+ super().__init__(*args,**kwargs)
+
+
+
[docs]classCmdSet(object,metaclass=_CmdSetMeta):
+ """
+ This class describes a unique cmdset that understands priorities.
+ CmdSets can be merged and made to perform various set operations
+ on each other. CmdSets have priorities that affect which of their
+ ingoing commands gets used.
+
+ In the examples, cmdset A always have higher priority than cmdset B.
+
+ key - the name of the cmdset. This can be used on its own for game
+ operations
+
+ mergetype (partly from Set theory):
+
+ Union - The two command sets are merged so that as many
+ commands as possible of each cmdset ends up in the
+ merged cmdset. Same-name commands are merged by
+ priority. This is the most common default.
+ Ex: A1,A3 + B1,B2,B4,B5 = A1,B2,A3,B4,B5
+ Intersect - Only commands found in *both* cmdsets
+ (i.e. which have same names) end up in the merged
+ cmdset, with the higher-priority cmdset replacing the
+ lower one. Ex: A1,A3 + B1,B2,B4,B5 = A1
+ Replace - The commands of this cmdset completely replaces
+ the lower-priority cmdset's commands, regardless
+ of if same-name commands exist.
+ Ex: A1,A3 + B1,B2,B4,B5 = A1,A3
+ Remove - This removes the relevant commands from the
+ lower-priority cmdset completely. They are not
+ replaced with anything, so this in effects uses the
+ high-priority cmdset as a filter to affect the
+ low-priority cmdset.
+ Ex: A1,A3 + B1,B2,B4,B5 = B2,B4,B5
+
+ Note: Commands longer than 2 characters and starting
+ with double underscrores, like '__noinput_command'
+ are considered 'system commands' and are
+ excempt from all merge operations - they are
+ ALWAYS included across mergers and only affected
+ if same-named system commands replace them.
+
+ priority- All cmdsets are always merged in pairs of two so that
+ the higher set's mergetype is applied to the
+ lower-priority cmdset. Default commands have priority 0,
+ high-priority ones like Exits and Channels have 10 and 9.
+ Priorities can be negative as well to give default
+ commands preference.
+
+ duplicates - determines what happens when two sets of equal
+ priority merge (only). Defaults to None and has the first of them in the
+ merger (i.e. A above) automatically taking
+ precedence. But if `duplicates` is true, the
+ result will be a merger with more than one of each
+ name match. This will usually lead to the account
+ receiving a multiple-match error higher up the road,
+ but can be good for things like cmdsets on non-account
+ objects in a room, to allow the system to warn that
+ more than one 'ball' in the room has the same 'kick'
+ command defined on it, so it may offer a chance to
+ select which ball to kick ... Allowing duplicates
+ only makes sense for Union and Intersect, the setting
+ is ignored for the other mergetypes.
+ Note that the `duplicates` flag is *not* propagated in
+ a cmdset merger. So `A + B = C` will result in
+ a cmdset with duplicate commands, but C.duplicates will
+ be `None`. For duplication to apply to a whole cmdset
+ stack merge, _all_ cmdsets in the stack must have
+ `.duplicates=True` set.
+ Finally, if a final cmdset has `.duplicates=None` (the normal
+ unless created alone with another value), the cmdhandler
+ will assume True for object-based cmdsets and False for
+ all other. This is usually the most intuitive outcome.
+
+ key_mergetype (dict) - allows the cmdset to define a unique
+ mergetype for particular cmdsets. Format is
+ {CmdSetkeystring:mergetype}. Priorities still apply.
+ Example: {'Myevilcmdset','Replace'} which would make
+ sure for this set to always use 'Replace' on
+ Myevilcmdset no matter what overall mergetype this set
+ has.
+
+ no_objs - don't include any commands from nearby objects
+ when searching for suitable commands
+ no_exits - ignore the names of exits when matching against
+ commands
+ no_channels - ignore the name of channels when matching against
+ commands (WARNING- this is dangerous since the
+ account can then not even ask staff for help if
+ something goes wrong)
+
+
+ """
+
+ key="Unnamed CmdSet"
+ mergetype="Union"
+ priority=0
+
+ # These flags, if set to None should be interpreted as 'I don't care' and,
+ # will allow "pass-through" even of lower-prio cmdsets' explicitly True/False
+ # options. If this is set to True/False however, priority matters.
+ no_exits=None
+ no_objs=None
+ no_channels=None
+ # The .duplicates setting does not propagate and since duplicates can only happen
+ # on same-prio cmdsets, there is no concept of passthrough on `None`.
+ # The merger of two cmdsets always return in a cmdset with `duplicates=None`
+ # (even if the result may have duplicated commands).
+ # If a final cmdset has `duplicates=None` (normal, unless the cmdset is
+ # created on its own with the flag set), the cmdhandler will auto-assume it to be
+ # True for Object-based cmdsets and stay None/False for all other entities.
+ #
+ # Example:
+ # A and C has .duplicates=True, B has .duplicates=None (or False)
+ # B + A = BA, where BA will have duplicate cmds, but BA.duplicates = None
+ # BA + C = BAC, where BAC will have more duplication, but BAC.duplicates = None
+ #
+ # Basically, for the `.duplicate` setting to survive throughout a
+ # merge-stack, every cmdset in the stack must have `duplicates` set explicitly.
+ duplicates=None
+
+ permanent=False
+ key_mergetypes={}
+ errmessage=""
+ # pre-store properties to duplicate straight off
+ to_duplicate=(
+ "key",
+ "cmdsetobj",
+ "no_exits",
+ "no_objs",
+ "no_channels",
+ "permanent",
+ "mergetype",
+ "priority",
+ "duplicates",
+ "errmessage",
+ )
+
+
[docs]def__init__(self,cmdsetobj=None,key=None):
+ """
+ Creates a new CmdSet instance.
+
+ Args:
+ cmdsetobj (Session, Account, Object, optional): This is the database object
+ to which this particular instance of cmdset is related. It
+ is often a character but may also be a regular object, Account
+ or Session.
+ key (str, optional): The idenfier for this cmdset. This
+ helps if wanting to selectively remov cmdsets.
+
+ """
+
+ ifkey:
+ self.key=key
+ self.commands=[]
+ self.system_commands=[]
+ self.actual_mergetype=self.mergetype
+ self.cmdsetobj=cmdsetobj
+ # this is set only on merged sets, in cmdhandler.py, in order to
+ # track, list and debug mergers correctly.
+ self.merged_from=[]
+
+ # initialize system
+ self.at_cmdset_creation()
+ self._contains_cache=WeakKeyDictionary()# {}
+
+ # Priority-sensitive merge operations for cmdsets
+
+ def_union(self,cmdset_a,cmdset_b):
+ """
+ Merge two sets using union merger
+
+ Args:
+ cmdset_a (Cmdset): Cmdset given higher priority in the case of a tie.
+ cmdset_b (Cmdset): Cmdset given lower priority in the case of a tie.
+
+ Returns:
+ cmdset_c (Cmdset): The result of A U B operation.
+
+ Notes:
+ Union, C = A U B, means that C gets all elements from both A and B.
+
+ """
+ cmdset_c=cmdset_a._duplicate()
+ # we make copies, not refs by use of [:]
+ cmdset_c.commands=cmdset_a.commands[:]
+ ifcmdset_a.duplicatesandcmdset_a.priority==cmdset_b.priority:
+ cmdset_c.commands.extend(cmdset_b.commands)
+ else:
+ cmdset_c.commands.extend([cmdforcmdincmdset_bifcmdnotincmdset_a])
+ returncmdset_c
+
+ def_intersect(self,cmdset_a,cmdset_b):
+ """
+ Merge two sets using intersection merger
+
+ Args:
+ cmdset_a (Cmdset): Cmdset given higher priority in the case of a tie.
+ cmdset_b (Cmdset): Cmdset given lower priority in the case of a tie.
+
+ Returns:
+ cmdset_c (Cmdset): The result of A (intersect) B operation.
+
+ Notes:
+ Intersection, C = A (intersect) B, means that C only gets the
+ parts of A and B that are the same (that is, the commands
+ of each set having the same name. Only the one of these
+ having the higher prio ends up in C).
+
+ """
+ cmdset_c=cmdset_a._duplicate()
+ ifcmdset_a.duplicatesandcmdset_a.priority==cmdset_b.priority:
+ forcmdin[cmdforcmdincmdset_aifcmdincmdset_b]:
+ cmdset_c.add(cmd)
+ cmdset_c.add(cmdset_b.get(cmd))
+ else:
+ cmdset_c.commands=[cmdforcmdincmdset_aifcmdincmdset_b]
+ returncmdset_c
+
+ def_replace(self,cmdset_a,cmdset_b):
+ """
+ Replace the contents of one set with another
+
+ Args:
+ cmdset_a (Cmdset): Cmdset replacing
+ cmdset_b (Cmdset): Cmdset to replace
+
+ Returns:
+ cmdset_c (Cmdset): This is indentical to cmdset_a.
+
+ Notes:
+ C = A, where B is ignored.
+
+ """
+ cmdset_c=cmdset_a._duplicate()
+ cmdset_c.commands=cmdset_a.commands[:]
+ returncmdset_c
+
+ def_remove(self,cmdset_a,cmdset_b):
+ """
+ Filter a set by another.
+
+ Args:
+ cmdset_a (Cmdset): Cmdset acting as a removal filter.
+ cmdset_b (Cmdset): Cmdset to filter
+
+ Returns:
+ cmdset_c (Cmdset): B, with all matching commands from A removed.
+
+ Notes:
+ C = B - A, where A is used to remove the commands of B.
+
+ """
+
+ cmdset_c=cmdset_a._duplicate()
+ cmdset_c.commands=[cmdforcmdincmdset_bifcmdnotincmdset_a]
+ returncmdset_c
+
+ def_instantiate(self,cmd):
+ """
+ checks so that object is an instantiated command and not, say
+ a cmdclass. If it is, instantiate it. Other types, like
+ strings, are passed through.
+
+ Args:
+ cmd (any): Entity to analyze.
+
+ Returns:
+ result (any): An instantiated Command or the input unmodified.
+
+ """
+ ifcallable(cmd):
+ returncmd()
+ else:
+ returncmd
+
+ def_duplicate(self):
+ """
+ Returns a new cmdset with the same settings as this one (no
+ actual commands are copied over)
+
+ Returns:
+ cmdset (Cmdset): A copy of the current cmdset.
+ """
+ cmdset=CmdSet()
+ forkey,valin((key,getattr(self,key))forkeyinself.to_duplicate):
+ ifval!=getattr(cmdset,key):
+ # only copy if different from default; avoid turning
+ # class-vars into instance vars
+ setattr(cmdset,key,val)
+ cmdset.key_mergetypes=self.key_mergetypes.copy()
+ returncmdset
+
+ def__str__(self):
+ """
+ Show all commands in cmdset when printing it.
+
+ Returns:
+ commands (str): Representation of commands in Cmdset.
+
+ """
+ perm="perm"ifself.permanentelse"non-perm"
+ options=", ".join(
+ [
+ "{}:{}".format(opt,"T"ifgetattr(self,opt)else"F")
+ foroptin("no_exits","no_objs","no_channels","duplicates")
+ ifgetattr(self,opt)isnotNone
+ ]
+ )
+ options=(", "+options)ifoptionselse""
+ return(
+ f"<CmdSet {self.key}, {self.mergetype}, {perm}, prio {self.priority}{options}>: "
+ +", ".join([str(cmd)forcmdinsorted(self.commands,key=lambdao:o.key)])
+ )
+
+ def__iter__(self):
+ """
+ Allows for things like 'for cmd in cmdset':
+
+ Returns:
+ iterable (iter): Commands in Cmdset.
+
+ """
+ returniter(self.commands)
+
+ def__contains__(self,othercmd):
+ """
+ Returns True if this cmdset contains the given command (as
+ defined by command name and aliases). This allows for things
+ like 'if cmd in cmdset'
+
+ """
+ ret=self._contains_cache.get(othercmd)
+ ifretisNone:
+ ret=othercmdinself.commands
+ self._contains_cache[othercmd]=ret
+ returnret
+
+ def__add__(self,cmdset_a):
+ """
+ Merge this cmdset (B) with another cmdset (A) using the + operator,
+
+ C = B + A
+
+ Here, we (by convention) say that 'A is merged onto B to form
+ C'. The actual merge operation used in the 'addition' depends
+ on which priorities A and B have. The one of the two with the
+ highest priority will apply and give its properties to C. In
+ the case of a tie, A takes priority and replaces the
+ same-named commands in B unless A has the 'duplicate' variable
+ set (which means both sets' commands are kept).
+ """
+
+ # It's okay to merge with None
+ ifnotcmdset_a:
+ returnself
+
+ sys_commands_a=cmdset_a.get_system_cmds()
+ sys_commands_b=self.get_system_cmds()
+
+ ifself.priority<=cmdset_a.priority:
+ # A higher or equal priority to B
+
+ # preserve system __commands
+ sys_commands=sys_commands_a+[
+ cmdforcmdinsys_commands_bifcmdnotinsys_commands_a
+ ]
+
+ mergetype=cmdset_a.key_mergetypes.get(self.key,cmdset_a.mergetype)
+ ifmergetype=="Intersect":
+ cmdset_c=self._intersect(cmdset_a,self)
+ elifmergetype=="Replace":
+ cmdset_c=self._replace(cmdset_a,self)
+ elifmergetype=="Remove":
+ cmdset_c=self._remove(cmdset_a,self)
+ else:# Union
+ cmdset_c=self._union(cmdset_a,self)
+
+ # pass through options whenever they are set, unless the merging or higher-prio
+ # set changes the setting (i.e. has a non-None value). We don't pass through
+ # the duplicates setting; that is per-merge; the resulting .duplicates value
+ # is always None (so merging cmdsets must all have explicit values if wanting
+ # to cause duplicates).
+ cmdset_c.no_channels=(
+ self.no_channelsifcmdset_a.no_channelsisNoneelsecmdset_a.no_channels
+ )
+ cmdset_c.no_exits=self.no_exitsifcmdset_a.no_exitsisNoneelsecmdset_a.no_exits
+ cmdset_c.no_objs=self.no_objsifcmdset_a.no_objsisNoneelsecmdset_a.no_objs
+ cmdset_c.duplicates=None
+
+ else:
+ # B higher priority than A
+
+ # preserver system __commands
+ sys_commands=sys_commands_b+[
+ cmdforcmdinsys_commands_aifcmdnotinsys_commands_b
+ ]
+
+ mergetype=self.key_mergetypes.get(cmdset_a.key,self.mergetype)
+ ifmergetype=="Intersect":
+ cmdset_c=self._intersect(self,cmdset_a)
+ elifmergetype=="Replace":
+ cmdset_c=self._replace(self,cmdset_a)
+ elifmergetype=="Remove":
+ cmdset_c=self._remove(self,cmdset_a)
+ else:# Union
+ cmdset_c=self._union(self,cmdset_a)
+
+ # pass through options whenever they are set, unless the higher-prio
+ # set changes the setting (i.e. has a non-None value). We don't pass through
+ # the duplicates setting; that is per-merge; the resulting .duplicates value#
+ # is always None (so merging cmdsets must all have explicit values if wanting
+ # to cause duplicates).
+ cmdset_c.no_channels=(
+ cmdset_a.no_channelsifself.no_channelsisNoneelseself.no_channels
+ )
+ cmdset_c.no_exits=cmdset_a.no_exitsifself.no_exitsisNoneelseself.no_exits
+ cmdset_c.no_objs=cmdset_a.no_objsifself.no_objsisNoneelseself.no_objs
+ cmdset_c.duplicates=None
+
+ # we store actual_mergetype since key_mergetypes
+ # might be different from the main mergetype.
+ # This is used for diagnosis.
+ cmdset_c.actual_mergetype=mergetype
+
+ # print "__add__ for %s (prio %i) called with %s (prio %i)." % (self.key, self.priority, cmdset_a.key, cmdset_a.priority)
+
+ # return the system commands to the cmdset
+ cmdset_c.add(sys_commands,allow_duplicates=True)
+ returncmdset_c
+
+
[docs]defadd(self,cmd,allow_duplicates=False):
+ """
+ Add a new command or commands to this CmdSet, a list of
+ commands or a cmdset to this cmdset. Note that this is *not*
+ a merge operation (that is handled by the + operator).
+
+ Args:
+ cmd (Command, list, Cmdset): This allows for adding one or
+ more commands to this Cmdset in one go. If another Cmdset
+ is given, all its commands will be added.
+ allow_duplicates (bool, optional): If set, will not try to remove
+ duplicate cmds in the set. This is needed during the merge process
+ to avoid wiping commands coming from cmdsets with duplicate=True.
+
+ Notes:
+ If cmd already exists in set, it will replace the old one
+ (no priority checking etc happens here). This is very useful
+ when overloading default commands).
+
+ If cmd is another cmdset class or -instance, the commands of
+ that command set is added to this one, as if they were part of
+ the original cmdset definition. No merging or priority checks
+ are made, rather later added commands will simply replace
+ existing ones to make a unique set.
+
+ """
+
+ ifinherits_from(cmd,"evennia.commands.cmdset.CmdSet"):
+ # cmd is a command set so merge all commands in that set
+ # to this one. We raise a visible error if we created
+ # an infinite loop (adding cmdset to itself somehow)
+ cmdset=cmd
+ try:
+ cmdset=self._instantiate(cmdset)
+ exceptRuntimeError:
+ err=("Adding cmdset {cmdset} to {cls} lead to an "
+ "infinite loop. When adding a cmdset to another, "
+ "make sure they are not themself cyclically added to "
+ "the new cmdset somewhere in the chain.")
+ raiseRuntimeError(_(err.format(cmdset=cmdset,cls=self.__class__)))
+ cmds=cmdset.commands
+ elifis_iter(cmd):
+ cmds=[self._instantiate(c)forcincmd]
+ else:
+ cmds=[self._instantiate(cmd)]
+ commands=self.commands
+ system_commands=self.system_commands
+ forcmdincmds:
+ # add all commands
+ ifnothasattr(cmd,"obj")orcmd.objisNone:
+ cmd.obj=self.cmdsetobj
+ try:
+ ic=commands.index(cmd)
+ commands[ic]=cmd# replace
+ exceptValueError:
+ commands.append(cmd)
+ self.commands=commands
+ ifnotallow_duplicates:
+ # extra run to make sure to avoid doublets
+ self.commands=list(set(self.commands))
+ # add system_command to separate list as well,
+ # for quick look-up
+ ifcmd.key.startswith("__"):
+ try:
+ ic=system_commands.index(cmd)
+ system_commands[ic]=cmd# replace
+ exceptValueError:
+ system_commands.append(cmd)
+
+
[docs]defremove(self,cmd):
+ """
+ Remove a command instance from the cmdset.
+
+ Args:
+ cmd (Command or str): Either the Command object to remove
+ or the key of such a command.
+
+ """
+ cmd=self._instantiate(cmd)
+ ifcmd.key.startswith("__"):
+ try:
+ ic=self.system_commands.index(cmd)
+ delself.system_commands[ic]
+ exceptValueError:
+ # ignore error
+ pass
+ else:
+ self.commands=[oldcmdforoldcmdinself.commandsifoldcmd!=cmd]
+
+
[docs]defget(self,cmd):
+ """
+ Get a command from the cmdset. This is mostly useful to
+ check if the command is part of this cmdset or not.
+
+ Args:
+ cmd (Command or str): Either the Command object or its key.
+
+ Returns:
+ cmd (Command): The first matching Command in the set.
+
+ """
+ cmd=self._instantiate(cmd)
+ forthiscmdinself.commands:
+ ifthiscmd==cmd:
+ returnthiscmd
+ returnNone
+
+
[docs]defcount(self):
+ """
+ Number of commands in set.
+
+ Returns:
+ N (int): Number of commands in this Cmdset.
+
+ """
+ returnlen(self.commands)
+
+
[docs]defget_system_cmds(self):
+ """
+ Get system commands in cmdset
+
+ Returns:
+ sys_cmds (list): The system commands in the set.
+
+ Notes:
+ As far as the Cmdset is concerned, system commands are any
+ commands with a key starting with double underscore __.
+ These are excempt from merge operations.
+
+ """
+ returnself.system_commands
+
+
[docs]defmake_unique(self,caller):
+ """
+ Remove duplicate command-keys (unsafe)
+
+ Args:
+ caller (object): Commands on this object will
+ get preference in the duplicate removal.
+
+ Notes:
+ This is an unsafe command meant to clean out a cmdset of
+ doublet commands after it has been created. It is useful
+ for commands inheriting cmdsets from the cmdhandler where
+ obj-based cmdsets always are added double. Doublets will
+ be weeded out with preference to commands defined on
+ caller, otherwise just by first-come-first-served.
+
+ """
+ unique={}
+ forcmdinself.commands:
+ ifcmd.keyinunique:
+ ocmd=unique[cmd.key]
+ if(hasattr(cmd,"obj")andcmd.obj==caller)andnot(
+ hasattr(ocmd,"obj")andocmd.obj==caller
+ ):
+ unique[cmd.key]=cmd
+ else:
+ unique[cmd.key]=cmd
+ self.commands=list(unique.values())
+
+
[docs]defget_all_cmd_keys_and_aliases(self,caller=None):
+ """
+ Collects keys/aliases from commands
+
+ Args:
+ caller (Object, optional): If set, this is used to check access permissions
+ on each command. Only commands that pass are returned.
+
+ Returns:
+ names (list): A list of all command keys and aliases in this cmdset. If `caller`
+ was given, this list will only contain commands to which `caller` passed
+ the `call` locktype check.
+
+ """
+ names=[]
+ ifcaller:
+ [names.extend(cmd._keyaliases)forcmdinself.commandsifcmd.access(caller)]
+ else:
+ [names.extend(cmd._keyaliases)forcmdinself.commands]
+ returnnames
+
+
[docs]defat_cmdset_creation(self):
+ """
+ Hook method - this should be overloaded in the inheriting
+ class, and should take care of populating the cmdset by use of
+ self.add().
+ """
+ pass
+"""
+CmdSethandler
+
+The Cmdsethandler tracks an object's 'Current CmdSet', which is the
+current merged sum of all CmdSets added to it.
+
+A CmdSet constitues a set of commands. The CmdSet works as a special
+intelligent container that, when added to other CmdSet make sure that
+same-name commands are treated correctly (usually so there are no
+doublets). This temporary but up-to-date merger of CmdSet is jointly
+called the Current Cmset. It is this Current CmdSet that the
+commandhandler looks through whenever an account enters a command (it
+also adds CmdSets from objects in the room in real-time). All account
+objects have a 'default cmdset' containing all the normal in-game mud
+commands (look etc).
+
+So what is all this cmdset complexity good for?
+
+In its simplest form, a CmdSet has no commands, only a key name. In
+this case the cmdset's use is up to each individual game - it can be
+used by an AI module for example (mobs in cmdset 'roam' move from room
+to room, in cmdset 'attack' they enter combat with accounts).
+
+Defining commands in cmdsets offer some further powerful game-design
+consequences however. Here are some examples:
+
+As mentioned above, all accounts always have at least the Default
+CmdSet. This contains the set of all normal-use commands in-game,
+stuff like look and @desc etc. Now assume our players end up in a dark
+room. You don't want the player to be able to do much in that dark
+room unless they light a candle. You could handle this by changing all
+your normal commands to check if the player is in a dark room. This
+rapidly goes unwieldly and error prone. Instead you just define a
+cmdset with only those commands you want to be available in the 'dark'
+cmdset - maybe a modified look command and a 'light candle' command -
+and have this completely replace the default cmdset.
+
+Another example: Say you want your players to be able to go
+fishing. You could implement this as a 'fish' command that fails
+whenever the account has no fishing rod. Easy enough. But what if you
+want to make fishing more complex - maybe you want four-five different
+commands for throwing your line, reeling in, etc? Most players won't
+(we assume) have fishing gear, and having all those detailed commands
+is cluttering up the command list. And what if you want to use the
+'throw' command also for throwing rocks etc instead of 'using it up'
+for a minor thing like fishing?
+
+So instead you put all those detailed fishing commands into their own
+CommandSet called 'Fishing'. Whenever the player gives the command
+'fish' (presumably the code checks there is also water nearby), only
+THEN this CommandSet is added to the Cmdhandler of the account. The
+'throw' command (which normally throws rocks) is replaced by the
+custom 'fishing variant' of throw. What has happened is that the
+Fishing CommandSet was merged on top of the Default ones, and due to
+how we defined it, its command overrules the default ones.
+
+When we are tired of fishing, we give the 'go home' command (or
+whatever) and the Cmdhandler simply removes the fishing CommandSet
+so that we are back at defaults (and can throw rocks again).
+
+Since any number of CommandSets can be piled on top of each other, you
+can then implement separate sets for different situations. For
+example, you can have a 'On a boat' set, onto which you then tack on
+the 'Fishing' set. Fishing from a boat? No problem!
+"""
+importsys
+fromtracebackimportformat_exc
+fromimportlibimportimport_module
+frominspectimporttrace
+fromdjango.confimportsettings
+fromevennia.utilsimportlogger,utils
+fromevennia.commands.cmdsetimportCmdSet
+fromevennia.server.modelsimportServerConfig
+
+fromdjango.utils.translationimportgettextas_
+
+__all__=("import_cmdset","CmdSetHandler")
+
+_CACHED_CMDSETS={}
+_CMDSET_PATHS=utils.make_iter(settings.CMDSET_PATHS)
+_IN_GAME_ERRORS=settings.IN_GAME_ERRORS
+_CMDSET_FALLBACKS=settings.CMDSET_FALLBACKS
+
+
+# Output strings
+
+_ERROR_CMDSET_IMPORT=_(
+ """{traceback}
+Error loading cmdset '{path}'
+(Traceback was logged {timestamp})"""
+)
+
+_ERROR_CMDSET_KEYERROR=_(
+ """Error loading cmdset: No cmdset class '{classname}' in '{path}'.
+(Traceback was logged {timestamp})"""
+)
+
+_ERROR_CMDSET_SYNTAXERROR=_(
+ """{traceback}
+SyntaxError encountered when loading cmdset '{path}'.
+(Traceback was logged {timestamp})"""
+)
+
+_ERROR_CMDSET_EXCEPTION=_(
+ """{traceback}
+Compile/Run error when loading cmdset '{path}'.",
+(Traceback was logged {timestamp})"""
+)
+
+_ERROR_CMDSET_FALLBACK=_(
+ """
+Error encountered for cmdset at path '{path}'.
+Replacing with fallback '{fallback_path}'.
+"""
+)
+
+_ERROR_CMDSET_NO_FALLBACK=_("""Fallback path '{fallback_path}' failed to generate a cmdset.""")
+
+
+class_ErrorCmdSet(CmdSet):
+ """
+ This is a special cmdset used to report errors.
+ """
+
+ key="_CMDSET_ERROR"
+ errmessage="Error when loading cmdset."
+
+
+class_EmptyCmdSet(CmdSet):
+ """
+ This cmdset represents an empty cmdset
+ """
+
+ key="_EMPTY_CMDSET"
+ priority=-101
+ mergetype="Union"
+
+
+
[docs]defimport_cmdset(path,cmdsetobj,emit_to_obj=None,no_logging=False):
+ """
+ This helper function is used by the cmdsethandler to load a cmdset
+ instance from a python module, given a python_path. It's usually accessed
+ through the cmdsethandler's add() and add_default() methods.
+ path - This is the full path to the cmdset object on python dot-form
+
+ Args:
+ path (str): The path to the command set to load.
+ cmdsetobj (CmdSet): The database object/typeclass on which this cmdset is to be
+ assigned (this can be also channels and exits, as well as accounts
+ but there will always be such an object)
+ emit_to_obj (Object, optional): If given, error is emitted to
+ this object (in addition to logging)
+ no_logging (bool, optional): Don't log/send error messages.
+ This can be useful if import_cmdset is just used to check if
+ this is a valid python path or not.
+ Returns:
+ cmdset (CmdSet): The imported command set. If an error was
+ encountered, `commands.cmdsethandler._ErrorCmdSet` is returned
+ for the benefit of the handler.
+
+ """
+ python_paths=[path]+[
+ "%s.%s"%(prefix,path)forprefixin_CMDSET_PATHSifnotpath.startswith(prefix)
+ ]
+ errstring=""
+ forpython_pathinpython_paths:
+
+ if"."inpath:
+ modpath,classname=python_path.rsplit(".",1)
+ else:
+ raiseImportError("The path '%s' is not on the form modulepath.ClassName"%path)
+
+ try:
+ # first try to get from cache
+ cmdsetclass=_CACHED_CMDSETS.get(python_path,None)
+
+ ifnotcmdsetclass:
+ try:
+ module=import_module(modpath,package="evennia")
+ exceptImportErrorasexc:
+ iflen(trace())>2:
+ # error in module, make sure to not hide it.
+ dum,dum,tb=sys.exc_info()
+ raiseexc.with_traceback(tb)
+ else:
+ # try next suggested path
+ errstring+=_("\n(Unsuccessfully tried '{path}').").format(
+ path=python_path
+ )
+ continue
+ try:
+ cmdsetclass=getattr(module,classname)
+ exceptAttributeErrorasexc:
+ iflen(trace())>2:
+ # Attribute error within module, don't hide it
+ dum,dum,tb=sys.exc_info()
+ raiseexc.with_traceback(tb)
+ else:
+ errstring+=_("\n(Unsuccessfully tried '{path}').").format(
+ path=python_path
+ )
+ continue
+ _CACHED_CMDSETS[python_path]=cmdsetclass
+
+ # instantiate the cmdset (and catch its errors)
+ ifcallable(cmdsetclass):
+ cmdsetclass=cmdsetclass(cmdsetobj)
+ returncmdsetclass
+ exceptImportErroraserr:
+ logger.log_trace()
+ errstring+=_ERROR_CMDSET_IMPORT
+ if_IN_GAME_ERRORS:
+ errstring=errstring.format(
+ path=python_path,traceback=format_exc(),timestamp=logger.timeformat()
+ )
+ else:
+ errstring=errstring.format(
+ path=python_path,traceback=err,timestamp=logger.timeformat()
+ )
+ break
+ exceptKeyError:
+ logger.log_trace()
+ errstring+=_ERROR_CMDSET_KEYERROR
+ errstring=errstring.format(
+ classname=classname,path=python_path,timestamp=logger.timeformat()
+ )
+ break
+ exceptSyntaxErroraserr:
+ logger.log_trace()
+ errstring+=_ERROR_CMDSET_SYNTAXERROR
+ if_IN_GAME_ERRORS:
+ errstring=errstring.format(
+ path=python_path,traceback=format_exc(),timestamp=logger.timeformat()
+ )
+ else:
+ errstring=errstring.format(
+ path=python_path,traceback=err,timestamp=logger.timeformat()
+ )
+ break
+ exceptExceptionaserr:
+ logger.log_trace()
+ errstring+=_ERROR_CMDSET_EXCEPTION
+ if_IN_GAME_ERRORS:
+ errstring=errstring.format(
+ path=python_path,traceback=format_exc(),timestamp=logger.timeformat()
+ )
+ else:
+ errstring=errstring.format(
+ path=python_path,traceback=err,timestamp=logger.timeformat()
+ )
+ break
+
+ iferrstring:
+ # returning an empty error cmdset
+ errstring=errstring.strip()
+ ifnotno_logging:
+ logger.log_err(errstring)
+ ifemit_to_objandnotServerConfig.objects.conf("server_starting_mode"):
+ emit_to_obj.msg(errstring)
+ err_cmdset=_ErrorCmdSet()
+ err_cmdset.errmessage=errstring
+ returnerr_cmdset
+ returnNone# undefined error
+
+
+# classes
+
+
+
[docs]classCmdSetHandler(object):
+ """
+ The CmdSetHandler is always stored on an object, this object is supplied
+ as an argument.
+
+ The 'current' cmdset is the merged set currently active for this object.
+ This is the set the game engine will retrieve when determining which
+ commands are available to the object. The cmdset_stack holds a history of
+ all CmdSets to allow the handler to remove/add cmdsets at will. Doing so
+ will re-calculate the 'current' cmdset.
+ """
+
+
[docs]def__init__(self,obj,init_true=True):
+ """
+ This method is called whenever an object is recreated.
+
+ Args:
+ obj (Object): An reference to the game object this handler
+ belongs to.
+ init_true (bool, optional): Set when the handler is initializing
+ and loads the current cmdset.
+
+ """
+ self.obj=obj
+
+ # the id of the "merged" current cmdset for easy access.
+ self.key=None
+ # this holds the "merged" current command set. Note that while the .update
+ # method updates this field in order to have it synced when operating on
+ # cmdsets in-code, when the game runs, this field is kept up-to-date by
+ # the cmdsethandler's get_and_merge_cmdsets!
+ self.current=None
+ # this holds a history of CommandSets
+ self.cmdset_stack=[_EmptyCmdSet(cmdsetobj=self.obj)]
+ # this tracks which mergetypes are actually in play in the stack
+ self.mergetype_stack=["Union"]
+
+ # the subset of the cmdset_paths that are to be stored in the database
+ self.permanent_paths=[""]
+
+ ifinit_true:
+ self.update(init_mode=True)# is then called from the object __init__.
+
+ def__str__(self):
+ """
+ Display current commands
+ """
+
+ strings=["<CmdSetHandler> stack:"]
+ mergelist=[]
+ iflen(self.cmdset_stack)>1:
+ # We have more than one cmdset in stack; list them all
+ forsnum,cmdsetinenumerate(self.cmdset_stack):
+ mergelist.append(str(snum+1))
+ strings.append(f" {snum+1}: {cmdset}")
+
+ # Display the currently active cmdset, limited by self.obj's permissions
+ mergetype=self.mergetype_stack[-1]
+ ifmergetype!=self.current.mergetype:
+ merged_on=self.cmdset_stack[-2].key
+ mergetype=_("custom {mergetype} on cmdset '{cmdset}'")
+ mergetype=mergetype.format(mergetype=mergetype,cmdset=merged_on)
+
+ ifmergelist:
+ # current is a result of mergers
+ mergelist="+".join(mergelist)
+ strings.append(f" <Merged {mergelist}>: {self.current}")
+ else:
+ # current is a single cmdset
+ strings.append(" "+str(self.current))
+ return"\n".join(strings).rstrip()
+
+ def_import_cmdset(self,cmdset_path,emit_to_obj=None):
+ """
+ Method wrapper for import_cmdset; Loads a cmdset from a
+ module.
+
+ Args:
+ cmdset_path (str): The python path to an cmdset object.
+ emit_to_obj (Object): The object to send error messages to
+
+ Returns:
+ cmdset (Cmdset): The imported cmdset.
+
+ """
+ ifnotemit_to_obj:
+ emit_to_obj=self.obj
+ returnimport_cmdset(cmdset_path,self.obj,emit_to_obj)
+
+
[docs]defupdate(self,init_mode=False):
+ """
+ Re-adds all sets in the handler to have an updated current
+
+ Args:
+ init_mode (bool, optional): Used automatically right after
+ this handler was created; it imports all permanent cmdsets
+ from the database.
+
+ Notes:
+ This method is necessary in order to always have a `.current`
+ cmdset when working with the cmdsethandler in code. But the
+ CmdSetHandler doesn't (cannot) consider external cmdsets and game
+ state. This means that the .current calculated from this method
+ will likely not match the true current cmdset as determined at
+ run-time by `cmdhandler.get_and_merge_cmdsets()`. So in a running
+ game the responsibility of keeping `.current` upt-to-date belongs
+ to the central `cmdhandler.get_and_merge_cmdsets()`!
+
+ """
+ ifinit_mode:
+ # reimport all permanent cmdsets
+ storage=self.obj.cmdset_storage
+ ifstorage:
+ self.cmdset_stack=[]
+ forpos,pathinenumerate(storage):
+ ifpos==0andnotpath:
+ self.cmdset_stack=[_EmptyCmdSet(cmdsetobj=self.obj)]
+ elifpath:
+ cmdset=self._import_cmdset(path)
+ ifcmdset:
+ ifcmdset.key=="_CMDSET_ERROR":
+ # If a cmdset fails to load, check if we have a fallback path to use
+ fallback_path=_CMDSET_FALLBACKS.get(path,None)
+ iffallback_path:
+ err=_ERROR_CMDSET_FALLBACK.format(
+ path=path,fallback_path=fallback_path
+ )
+ logger.log_err(err)
+ if_IN_GAME_ERRORS:
+ self.obj.msg(err)
+ cmdset=self._import_cmdset(fallback_path)
+ # If no cmdset is returned from the fallback, we can't go further
+ ifnotcmdset:
+ err=_ERROR_CMDSET_NO_FALLBACK.format(
+ fallback_path=fallback_path
+ )
+ logger.log_err(err)
+ if_IN_GAME_ERRORS:
+ self.obj.msg(err)
+ continue
+ cmdset.permanent=cmdset.key!="_CMDSET_ERROR"
+ self.cmdset_stack.append(cmdset)
+
+ # merge the stack into a new merged cmdset
+ new_current=None
+ self.mergetype_stack=[]
+ forcmdsetinself.cmdset_stack:
+ try:
+ # for cmdset's '+' operator, order matters.
+ new_current=cmdset+new_current
+ exceptTypeError:
+ continue
+ self.mergetype_stack.append(new_current.actual_mergetype)
+ self.current=new_current
+
+
[docs]defadd(self,cmdset,emit_to_obj=None,permanent=False,default_cmdset=False):
+ """
+ Add a cmdset to the handler, on top of the old ones, unless it
+ is set as the default one (it will then end up at the bottom of the stack)
+
+ Args:
+ cmdset (CmdSet or str): Can be a cmdset object or the python path
+ to such an object.
+ emit_to_obj (Object, optional): An object to receive error messages.
+ permanent (bool, optional): This cmdset will remain across a server reboot.
+ default_cmdset (Cmdset, optional): Insert this to replace the
+ default cmdset position (there is only one such position,
+ always at the bottom of the stack).
+
+ Notes:
+ An interesting feature of this method is if you were to send
+ it an *already instantiated cmdset* (i.e. not a class), the
+ current cmdsethandler's obj attribute will then *not* be
+ transferred over to this already instantiated set (this is
+ because it might be used elsewhere and can cause strange
+ effects). This means you could in principle have the
+ handler launch command sets tied to a *different* object
+ than the handler. Not sure when this would be useful, but
+ it's a 'quirk' that has to be documented.
+
+ """
+ ifnot(isinstance(cmdset,str)orutils.inherits_from(cmdset,CmdSet)):
+ string=_("Only CmdSets can be added to the cmdsethandler!")
+ raiseException(string)
+
+ ifcallable(cmdset):
+ cmdset=cmdset(self.obj)
+ elifisinstance(cmdset,str):
+ # this is (maybe) a python path. Try to import from cache.
+ cmdset=self._import_cmdset(cmdset)
+ ifcmdsetandcmdset.key!="_CMDSET_ERROR":
+ cmdset.permanent=permanent
+ ifpermanentandcmdset.key!="_CMDSET_ERROR":
+ # store the path permanently
+ storage=self.obj.cmdset_storageor[""]
+ ifdefault_cmdset:
+ storage[0]=cmdset.path
+ else:
+ storage.append(cmdset.path)
+ self.obj.cmdset_storage=storage
+ ifdefault_cmdset:
+ self.cmdset_stack[0]=cmdset
+ else:
+ self.cmdset_stack.append(cmdset)
+ self.update()
+
+
[docs]defadd_default(self,cmdset,emit_to_obj=None,permanent=True):
+ """
+ Shortcut for adding a default cmdset.
+
+ Args:
+ cmdset (Cmdset): The Cmdset to add.
+ emit_to_obj (Object, optional): Gets error messages
+ permanent (bool, optional): The new Cmdset should survive a server reboot.
+
+ """
+ self.add(cmdset,emit_to_obj=emit_to_obj,permanent=permanent,default_cmdset=True)
+
+
[docs]defremove(self,cmdset=None,default_cmdset=False):
+ """
+ Remove a cmdset from the handler.
+
+ Args:
+ cmdset (CommandSet or str, optional): This can can be supplied either as a cmdset-key,
+ an instance of the CmdSet or a python path to the cmdset.
+ If no key is given, the last cmdset in the stack is
+ removed. Whenever the cmdset_stack changes, the cmdset is
+ updated. If default_cmdset is set, this argument is ignored.
+ default_cmdset (bool, optional): If set, this will remove the
+ default cmdset (at the bottom of the stack).
+
+ """
+ ifdefault_cmdset:
+ # remove the default cmdset only
+ ifself.cmdset_stack:
+ cmdset=self.cmdset_stack[0]
+ ifcmdset.permanent:
+ storage=self.obj.cmdset_storageor[""]
+ storage[0]=""
+ self.obj.cmdset_storage=storage
+ self.cmdset_stack[0]=_EmptyCmdSet(cmdsetobj=self.obj)
+ else:
+ self.cmdset_stack=[_EmptyCmdSet(cmdsetobj=self.obj)]
+ self.update()
+ return
+
+ iflen(self.cmdset_stack)<2:
+ # don't allow deleting default cmdsets here.
+ return
+
+ ifnotcmdset:
+ # remove the last one in the stack
+ cmdset=self.cmdset_stack.pop()
+ ifcmdset.permanent:
+ storage=self.obj.cmdset_storage
+ storage.pop()
+ self.obj.cmdset_storage=storage
+ else:
+ # try it as a callable
+ ifcallable(cmdset)andhasattr(cmdset,"path"):
+ delcmdsets=[csetforcsetinself.cmdset_stack[1:]ifcset.path==cmdset.path]
+ else:
+ # try it as a path or key
+ delcmdsets=[
+ cset
+ forcsetinself.cmdset_stack[1:]
+ ifcset.path==cmdsetorcset.key==cmdset
+ ]
+ storage=[]
+
+ ifany(cset.permanentforcsetindelcmdsets):
+ # only hit database if there's need to
+ storage=self.obj.cmdset_storage
+ updated=False
+ forcsetindelcmdsets:
+ ifcset.permanent:
+ try:
+ storage.remove(cset.path)
+ updated=True
+ exceptValueError:
+ # nothing to remove
+ pass
+ ifupdated:
+ self.obj.cmdset_storage=storage
+ forcsetindelcmdsets:
+ # clean the in-memory stack
+ try:
+ self.cmdset_stack.remove(cset)
+ exceptValueError:
+ # nothing to remove
+ pass
+ # re-sync the cmdsethandler.
+ self.update()
+
+ # legacy alias
+ delete=remove
+
+
[docs]defremove_default(self):
+ """
+ This explicitly deletes only the default cmdset.
+
+ """
+ self.remove(default_cmdset=True)
+
+ # legacy alias
+ delete_default=remove_default
+
+
[docs]defget(self):
+ """
+ Get all cmdsets.
+
+ Returns:
+ cmdsets (list): All the command sets currently in the handler.
+
+ """
+ returnself.cmdset_stack
+
+ # backwards-compatible alias
+ all=get
+
+
[docs]defclear(self):
+ """
+ Removes all Command Sets from the handler except the default one
+ (use `self.remove_default` to remove that).
+
+ """
+ self.cmdset_stack=[self.cmdset_stack[0]]
+ storage=self.obj.cmdset_storage
+ ifstorage:
+ storage=storage[0]
+ self.obj.cmdset_storage=storage
+ self.update()
+
+
[docs]defhas(self,cmdset,must_be_default=False):
+ """
+ checks so the cmdsethandler contains a given cmdset
+
+ Args:
+ cmdset (str or Cmdset): Cmdset key, pythonpath or
+ Cmdset to check the existence for.
+ must_be_default (bool, optional): Only return True if
+ the checked cmdset is the default one.
+
+ Returns:
+ has_cmdset (bool): Whether or not the cmdset is in the handler.
+
+ """
+ ifcallable(cmdset)andhasattr(cmdset,"path"):
+ # try it as a callable
+ ifmust_be_default:
+ returnself.cmdset_stackand(self.cmdset_stack[0].path==cmdset.path)
+ else:
+ returnany([csetforcsetinself.cmdset_stackifcset.path==cmdset.path])
+ else:
+ # try it as a path or key
+ ifmust_be_default:
+ returnself.cmdset_stackand(
+ self.cmdset_stack[0].key==cmdsetorself.cmdset_stack[0].path==cmdset
+ )
+ else:
+ returnany(
+ [
+ cset
+ forcsetinself.cmdset_stack
+ ifcset.path==cmdsetorcset.key==cmdset
+ ]
+ )
+
+ # backwards-compatability alias
+ has_cmdset=has
+
+
[docs]defreset(self):
+ """
+ Force reload of all cmdsets in handler. This should be called
+ after _CACHED_CMDSETS have been cleared (normally this is
+ handled automatically by @reload).
+
+ """
+ new_cmdset_stack=[]
+ forcmdsetinself.cmdset_stack:
+ ifcmdset.key=="_EMPTY_CMDSET":
+ new_cmdset_stack.append(cmdset)
+ else:
+ new_cmdset_stack.append(self._import_cmdset(cmdset.path))
+ self.cmdset_stack=new_cmdset_stack
+ self.update()
+"""
+The base Command class.
+
+All commands in Evennia inherit from the 'Command' class in this module.
+
+"""
+importre
+importmath
+importinspect
+
+fromdjango.confimportsettings
+
+fromevennia.locks.lockhandlerimportLockHandler
+fromevennia.utils.utilsimportis_iter,fill,lazy_property,make_iter
+fromevennia.utils.evtableimportEvTable
+fromevennia.utils.ansiimportANSIString
+
+
+def_init_command(cls,**kwargs):
+ """
+ Helper command.
+ Makes sure all data are stored as lowercase and
+ do checking on all properties that should be in list form.
+ Sets up locks to be more forgiving. This is used both by the metaclass
+ and (optionally) at instantiation time.
+
+ If kwargs are given, these are set as instance-specific properties
+ on the command - but note that the Command instance is *re-used* on a given
+ host object, so a kwarg value set on the instance will *remain* on the instance
+ for subsequent uses of that Command on that particular object.
+
+ """
+ foriinrange(len(kwargs)):
+ # used for dynamic creation of commands
+ key,value=kwargs.popitem()
+ setattr(cls,key,value)
+
+ cls.key=cls.key.lower()
+ ifcls.aliasesandnotis_iter(cls.aliases):
+ try:
+ cls.aliases=[str(alias).strip().lower()foraliasincls.aliases.split(",")]
+ exceptException:
+ cls.aliases=[]
+ cls.aliases=list(set(aliasforaliasincls.aliasesifaliasandalias!=cls.key))
+
+ # optimization - a set is much faster to match against than a list
+ cls._matchset=set([cls.key]+cls.aliases)
+ # optimization for looping over keys+aliases
+ cls._keyaliases=tuple(cls._matchset)
+
+ # by default we don't save the command between runs
+ ifnothasattr(cls,"save_for_next"):
+ cls.save_for_next=False
+
+ # pre-process locks as defined in class definition
+ temp=[]
+ ifhasattr(cls,"permissions"):
+ cls.locks=cls.permissions
+ ifnothasattr(cls,"locks"):
+ # default if one forgets to define completely
+ cls.locks="cmd:all()"
+ if"cmd:"notincls.locks:
+ cls.locks="cmd:all();"+cls.locks
+ forlockstringincls.locks.split(";"):
+ iflockstringand":"notinlockstring:
+ lockstring="cmd:%s"%lockstring
+ temp.append(lockstring)
+ cls.lock_storage=";".join(temp)
+
+ ifhasattr(cls,"arg_regex")andisinstance(cls.arg_regex,str):
+ cls.arg_regex=re.compile(r"%s"%cls.arg_regex,re.I+re.UNICODE)
+ ifnothasattr(cls,"auto_help"):
+ cls.auto_help=True
+ ifnothasattr(cls,"is_exit"):
+ cls.is_exit=False
+ ifnothasattr(cls,"help_category"):
+ cls.help_category="general"
+ # make sure to pick up the parent's docstring if the child class is
+ # missing one (important for auto-help)
+ ifcls.__doc__isNone:
+ forparent_classininspect.getmro(cls):
+ ifparent_class.__doc__isnotNone:
+ cls.__doc__=parent_class.__doc__
+ break
+ cls.help_category=cls.help_category.lower()
+
+
+
[docs]classCommandMeta(type):
+ """
+ The metaclass cleans up all properties on the class
+ """
+
+
+
+
+# The Command class is the basic unit of an Evennia command; when
+# defining new commands, the admin subclass this class and
+# define their own parser method to handle the input. The
+# advantage of this is inheritage; commands that have similar
+# structure can parse the input string the same way, minimizing
+# parsing errors.
+
+
+
[docs]classCommand(object,metaclass=CommandMeta):
+ """
+ Base command
+
+ Usage:
+ command [args]
+
+ This is the base command class. Inherit from this
+ to create new commands.
+
+ The cmdhandler makes the following variables available to the
+ command methods (so you can always assume them to be there):
+ self.caller - the game object calling the command
+ self.cmdstring - the command name used to trigger this command (allows
+ you to know which alias was used, for example)
+ cmd.args - everything supplied to the command following the cmdstring
+ (this is usually what is parsed in self.parse())
+ cmd.cmdset - the cmdset from which this command was matched (useful only
+ seldomly, notably for help-type commands, to create dynamic
+ help entries and lists)
+ cmd.obj - the object on which this command is defined. If a default command,
+ this is usually the same as caller.
+ cmd.rawstring - the full raw string input, including any args and no parsing.
+
+ The following class properties can/should be defined on your child class:
+
+ key - identifier for command (e.g. "look")
+ aliases - (optional) list of aliases (e.g. ["l", "loo"])
+ locks - lock string (default is "cmd:all()")
+ help_category - how to organize this help entry in help system
+ (default is "General")
+ auto_help - defaults to True. Allows for turning off auto-help generation
+ arg_regex - (optional) raw string regex defining how the argument part of
+ the command should look in order to match for this command
+ (e.g. must it be a space between cmdname and arg?)
+ auto_help_display_key - (optional) if given, this replaces the string shown
+ in the auto-help listing. This is particularly useful for system-commands
+ whose actual key is not really meaningful.
+
+ (Note that if auto_help is on, this initial string is also used by the
+ system to create the help entry for the command, so it's a good idea to
+ format it similar to this one). This behavior can be changed by
+ overriding the method 'get_help' of a command: by default, this
+ method returns cmd.__doc__ (that is, this very docstring, or
+ the docstring of your command). You can, however, extend or
+ replace this without disabling auto_help.
+ """
+
+ # the main way to call this command (e.g. 'look')
+ key="command"
+ # alternative ways to call the command (e.g. 'l', 'glance', 'examine')
+ aliases=[]
+ # a list of lock definitions on the form
+ # cmd:[NOT] func(args) [ AND|OR][ NOT] func2(args)
+ locks=settings.COMMAND_DEFAULT_LOCKS
+ # used by the help system to group commands in lists.
+ help_category=settings.COMMAND_DEFAULT_HELP_CATEGORY
+ # This allows to turn off auto-help entry creation for individual commands.
+ auto_help=True
+ # optimization for quickly separating exit-commands from normal commands
+ is_exit=False
+ # define the command not only by key but by the regex form of its arguments
+ arg_regex=settings.COMMAND_DEFAULT_ARG_REGEX
+ # whether self.msg sends to all sessions of a related account/object (default
+ # is to only send to the session sending the command).
+ msg_all_sessions=settings.COMMAND_DEFAULT_MSG_ALL_SESSIONS
+
+ # auto-set (by Evennia on command instantiation) are:
+ # obj - which object this command is defined on
+ # session - which session is responsible for triggering this command. Only set
+ # if triggered by an account.
+
+
[docs]def__init__(self,**kwargs):
+ """
+ The lockhandler works the same as for objects.
+ optional kwargs will be set as properties on the Command at runtime,
+ overloading evential same-named class properties.
+
+ """
+ ifkwargs:
+ _init_command(self,**kwargs)
+
+ def__str__(self):
+ """
+ Print the command key
+ """
+ returnself.key
+
+ def__eq__(self,cmd):
+ """
+ Compare two command instances to each other by matching their
+ key and aliases.
+
+ Args:
+ cmd (Command or str): Allows for equating both Command
+ objects and their keys.
+
+ Returns:
+ equal (bool): If the commands are equal or not.
+
+ """
+ try:
+ # first assume input is a command (the most common case)
+ returnself._matchset.intersection(cmd._matchset)
+ exceptAttributeError:
+ # probably got a string
+ returncmdinself._matchset
+
+ def__hash__(self):
+ """
+ Python 3 requires that any class which implements __eq__ must also
+ implement __hash__ and that the corresponding hashes for equivalent
+ instances are themselves equivalent.
+
+ Technically, the following implementation is only valid for comparison
+ against other Commands, as our __eq__ supports comparison against
+ str, too.
+
+ """
+ returnhash("\n".join(self._matchset))
+
+ def__ne__(self,cmd):
+ """
+ The logical negation of __eq__. Since this is one of the most
+ called methods in Evennia (along with __eq__) we do some
+ code-duplication here rather than issuing a method-lookup to
+ __eq__.
+ """
+ try:
+ returnself._matchset.isdisjoint(cmd._matchset)
+ exceptAttributeError:
+ returncmdnotinself._matchset
+
+ def__contains__(self,query):
+ """
+ This implements searches like 'if query in cmd'. It's a fuzzy
+ matching used by the help system, returning True if query can
+ be found as a substring of the commands key or its aliases.
+
+ Args:
+ query (str): query to match against. Should be lower case.
+
+ Returns:
+ result (bool): Fuzzy matching result.
+
+ """
+ returnany(queryinkeyaliasforkeyaliasinself._keyaliases)
+
+ def_optimize(self):
+ """
+ Optimize the key and aliases for lookups.
+ """
+ # optimization - a set is much faster to match against than a list
+ self._matchset=set([self.key]+self.aliases)
+ # optimization for looping over keys+aliases
+ self._keyaliases=tuple(self._matchset)
+
+
[docs]defset_key(self,new_key):
+ """
+ Update key.
+
+ Args:
+ new_key (str): The new key.
+
+ Notes:
+ This is necessary to use to make sure the optimization
+ caches are properly updated as well.
+
+ """
+ self.key=new_key.lower()
+ self._optimize()
+
+
[docs]defset_aliases(self,new_aliases):
+ """
+ Replace aliases with new ones.
+
+ Args:
+ new_aliases (str or list): Either a ;-separated string
+ or a list of aliases. These aliases will replace the
+ existing ones, if any.
+
+ Notes:
+ This is necessary to use to make sure the optimization
+ caches are properly updated as well.
+
+ """
+ ifisinstance(new_aliases,str):
+ new_aliases=new_aliases.split(";")
+ aliases=(str(alias).strip().lower()foraliasinmake_iter(new_aliases))
+ self.aliases=list(set(aliasforaliasinaliasesifalias!=self.key))
+ self._optimize()
+
+
[docs]defmatch(self,cmdname):
+ """
+ This is called by the system when searching the available commands,
+ in order to determine if this is the one we wanted. cmdname was
+ previously extracted from the raw string by the system.
+
+ Args:
+ cmdname (str): Always lowercase when reaching this point.
+
+ Returns:
+ result (bool): Match result.
+
+ """
+ returncmdnameinself._matchset
+
+
[docs]defaccess(self,srcobj,access_type="cmd",default=False):
+ """
+ This hook is called by the cmdhandler to determine if srcobj
+ is allowed to execute this command. It should return a boolean
+ value and is not normally something that need to be changed since
+ it's using the Evennia permission system directly.
+
+ Args:
+ srcobj (Object): Object trying to gain permission
+ access_type (str, optional): The lock type to check.
+ default (bool, optional): The fallback result if no lock
+ of matching `access_type` is found on this Command.
+
+ """
+ returnself.lockhandler.check(srcobj,access_type,default=default)
+
+
[docs]defmsg(self,text=None,to_obj=None,from_obj=None,session=None,**kwargs):
+ """
+ This is a shortcut instead of calling msg() directly on an
+ object - it will detect if caller is an Object or an Account and
+ also appends self.session automatically if self.msg_all_sessions is False.
+
+ Args:
+ text (str, optional): Text string of message to send.
+ to_obj (Object, optional): Target object of message. Defaults to self.caller.
+ from_obj (Object, optional): Source of message. Defaults to to_obj.
+ session (Session, optional): Supply data only to a unique
+ session (ignores the value of `self.msg_all_sessions`).
+
+ Keyword Args:
+ options (dict): Options to the protocol.
+ any (any): All other keywords are interpreted as th
+ name of send-instructions.
+
+ """
+ from_obj=from_objorself.caller
+ to_obj=to_objorfrom_obj
+ ifnotsessionandnotself.msg_all_sessions:
+ ifto_obj==self.caller:
+ session=self.session
+ else:
+ session=to_obj.sessions.get()
+ to_obj.msg(text=text,from_obj=from_obj,session=session,**kwargs)
+
+
[docs]defexecute_cmd(self,raw_string,session=None,obj=None,**kwargs):
+ """
+ A shortcut of execute_cmd on the caller. It appends the
+ session automatically.
+
+ Args:
+ raw_string (str): Execute this string as a command input.
+ session (Session, optional): If not given, the current command's Session will be used.
+ obj (Object or Account, optional): Object or Account on which to call the execute_cmd.
+ If not given, self.caller will be used.
+
+ Keyword Args:
+ Other keyword arguments will be added to the found command
+ object instace as variables before it executes. This is
+ unused by default Evennia but may be used to set flags and
+ change operating paramaters for commands at run-time.
+
+ """
+ obj=self.callerifobjisNoneelseobj
+ session=self.sessionifsessionisNoneelsesession
+ obj.execute_cmd(raw_string,session=session,**kwargs)
+
+ # Common Command hooks
+
+
[docs]defat_pre_cmd(self):
+ """
+ This hook is called before self.parse() on all commands. If
+ this hook returns anything but False/None, the command
+ sequence is aborted.
+
+ """
+ pass
+
+
[docs]defat_post_cmd(self):
+ """
+ This hook is called after the command has finished executing
+ (after self.func()).
+
+ """
+ pass
+
+
[docs]defparse(self):
+ """
+ Once the cmdhandler has identified this as the command we
+ want, this function is run. If many of your commands have a
+ similar syntax (for example 'cmd arg1 = arg2') you should
+ simply define this once and just let other commands of the
+ same form inherit from this. See the docstring of this module
+ for which object properties are available to use (notably
+ self.args).
+
+ """
+ pass
+
+
[docs]defget_command_info(self):
+ """
+ This is the default output of func() if no func() overload is done.
+ Provided here as a separate method so that it can be called for debugging
+ purposes when making commands.
+
+ """
+ variables="\n".join(
+ " |w{}|n ({}): {}".format(key,type(val),val)forkey,valinself.__dict__.items()
+ )
+ string=f"""
+Command {self} has no defined `func()` - showing on-command variables:
+{variables}
+ """
+ # a simple test command to show the available properties
+ string+="-"*50
+ string+="\n|w%s|n - Command variables from evennia:\n"%self.key
+ string+="-"*50
+ string+="\nname of cmd (self.key): |w%s|n\n"%self.key
+ string+="cmd aliases (self.aliases): |w%s|n\n"%self.aliases
+ string+="cmd locks (self.locks): |w%s|n\n"%self.locks
+ string+="help category (self.help_category): |w%s|n\n"%self.help_category.capitalize()
+ string+="object calling (self.caller): |w%s|n\n"%self.caller
+ string+="object storing cmdset (self.obj): |w%s|n\n"%self.obj
+ string+="command string given (self.cmdstring): |w%s|n\n"%self.cmdstring
+ # show cmdset.key instead of cmdset to shorten output
+ string+=fill(
+ "current cmdset (self.cmdset): |w%s|n\n"
+ %(self.cmdset.keyifself.cmdset.keyelseself.cmdset.__class__)
+ )
+
+ self.caller.msg(string)
+
+
[docs]deffunc(self):
+ """
+ This is the actual executing part of the command. It is
+ called directly after self.parse(). See the docstring of this
+ module for which object properties are available (beyond those
+ set in self.parse())
+
+ """
+ self.get_command_info()
+
+
[docs]defget_extra_info(self,caller,**kwargs):
+ """
+ Display some extra information that may help distinguish this
+ command from others, for instance, in a disambiguity prompt.
+
+ If this command is a potential match in an ambiguous
+ situation, one distinguishing feature may be its attachment to
+ a nearby object, so we include this if available.
+
+ Args:
+ caller (TypedObject): The caller who typed an ambiguous
+ term handed to the search function.
+
+ Returns:
+ A string with identifying information to disambiguate the
+ object, conventionally with a preceding space.
+
+ """
+ ifhasattr(self,"obj")andself.objandself.obj!=caller:
+ return" (%s)"%self.obj.get_display_name(caller).strip()
+ return""
+
+
[docs]defget_help(self,caller,cmdset):
+ """
+ Return the help message for this command and this caller.
+
+ By default, return self.__doc__ (the docstring just under
+ the class definition). You can override this behavior,
+ though, and even customize it depending on the caller, or other
+ commands the caller can use.
+
+ Args:
+ caller (Object or Account): the caller asking for help on the command.
+ cmdset (CmdSet): the command set (if you need additional commands).
+
+ Returns:
+ docstring (str): the help text to provide the caller for this command.
+
+ """
+ returnself.__doc__
+
+
[docs]defclient_width(self):
+ """
+ Get the client screenwidth for the session using this command.
+
+ Returns:
+ client width (int): The width (in characters) of the client window.
+
+ """
+ ifself.session:
+ returnself.session.protocol_flags.get(
+ "SCREENWIDTH",{0:settings.CLIENT_DEFAULT_WIDTH}
+ )[0]
+ returnsettings.CLIENT_DEFAULT_WIDTH
+
+
[docs]defclient_height(self):
+ """
+ Get the client screenheight for the session using this command.
+
+ Returns:
+ client height (int): The height (in characters) of the client window.
+
+ """
+ ifself.session:
+ returnself.session.protocol_flags.get(
+ "SCREENHEIGHT",{0:settings.CLIENT_DEFAULT_HEIGHT}
+ )[0]
+ returnsettings.CLIENT_DEFAULT_HEIGHT
+
+
[docs]defstyled_table(self,*args,**kwargs):
+ """
+ Create an EvTable styled by on user preferences.
+
+ Args:
+ *args (str): Column headers. If not colored explicitly, these will get colors
+ from user options.
+ Keyword Args:
+ any (str, int or dict): EvTable options, including, optionally a `table` dict
+ detailing the contents of the table.
+ Returns:
+ table (EvTable): An initialized evtable entity, either complete (if using `table` kwarg)
+ or incomplete and ready for use with `.add_row` or `.add_collumn`.
+
+ """
+ border_color=self.account.options.get("border_color")
+ column_color=self.account.options.get("column_names_color")
+
+ colornames=["|%s%s|n"%(column_color,col)forcolinargs]
+
+ h_line_char=kwargs.pop("header_line_char","~")
+ header_line_char=ANSIString(f"|{border_color}{h_line_char}|n")
+ c_char=kwargs.pop("corner_char","+")
+ corner_char=ANSIString(f"|{border_color}{c_char}|n")
+
+ b_left_char=kwargs.pop("border_left_char","||")
+ border_left_char=ANSIString(f"|{border_color}{b_left_char}|n")
+
+ b_right_char=kwargs.pop("border_right_char","||")
+ border_right_char=ANSIString(f"|{border_color}{b_right_char}|n")
+
+ b_bottom_char=kwargs.pop("border_bottom_char","-")
+ border_bottom_char=ANSIString(f"|{border_color}{b_bottom_char}|n")
+
+ b_top_char=kwargs.pop("border_top_char","-")
+ border_top_char=ANSIString(f"|{border_color}{b_top_char}|n")
+
+ table=EvTable(
+ *colornames,
+ header_line_char=header_line_char,
+ corner_char=corner_char,
+ border_left_char=border_left_char,
+ border_right_char=border_right_char,
+ border_top_char=border_top_char,
+ **kwargs,
+ )
+ returntable
+
+ def_render_decoration(
+ self,
+ header_text=None,
+ fill_character=None,
+ edge_character=None,
+ mode="header",
+ color_header=True,
+ width=None,
+ ):
+ """
+ Helper for formatting a string into a pretty display, for a header, separator or footer.
+
+ Keyword Args:
+ header_text (str): Text to include in header.
+ fill_character (str): This single character will be used to fill the width of the
+ display.
+ edge_character (str): This character caps the edges of the display.
+ mode(str): One of 'header', 'separator' or 'footer'.
+ color_header (bool): If the header should be colorized based on user options.
+ width (int): If not given, the client's width will be used if available.
+
+ Returns:
+ string (str): The decorated and formatted text.
+
+ """
+
+ colors=dict()
+ colors["border"]=self.account.options.get("border_color")
+ colors["headertext"]=self.account.options.get("%s_text_color"%mode)
+ colors["headerstar"]=self.account.options.get("%s_star_color"%mode)
+
+ width=widthorself.client_width()
+ ifedge_character:
+ width-=2
+
+ ifheader_text:
+ ifcolor_header:
+ header_text=ANSIString(header_text).clean()
+ header_text=ANSIString("|n|%s%s|n"%(colors["headertext"],header_text))
+ ifmode=="header":
+ begin_center=ANSIString(
+ "|n|%s<|%s* |n"%(colors["border"],colors["headerstar"])
+ )
+ end_center=ANSIString("|n |%s*|%s>|n"%(colors["headerstar"],colors["border"]))
+ center_string=ANSIString(begin_center+header_text+end_center)
+ else:
+ center_string=ANSIString("|n |%s%s |n"%(colors["headertext"],header_text))
+ else:
+ center_string=""
+
+ fill_character=self.account.options.get("%s_fill"%mode)
+
+ remain_fill=width-len(center_string)
+ ifremain_fill%2==0:
+ right_width=remain_fill/2
+ left_width=remain_fill/2
+ else:
+ right_width=math.floor(remain_fill/2)
+ left_width=math.ceil(remain_fill/2)
+
+ right_fill=ANSIString("|n|%s%s|n"%(colors["border"],fill_character*int(right_width)))
+ left_fill=ANSIString("|n|%s%s|n"%(colors["border"],fill_character*int(left_width)))
+
+ ifedge_character:
+ edge_fill=ANSIString("|n|%s%s|n"%(colors["border"],edge_character))
+ main_string=ANSIString(center_string)
+ final_send=(
+ ANSIString(edge_fill)+left_fill+main_string+right_fill+ANSIString(edge_fill)
+ )
+ else:
+ final_send=left_fill+ANSIString(center_string)+right_fill
+ returnfinal_send
+
+
+"""
+Account (OOC) commands. These are stored on the Account object
+and self.caller is thus always an Account, not an Object/Character.
+
+These commands go in the AccountCmdset and are accessible also
+when puppeting a Character (although with lower priority)
+
+These commands use the account_caller property which tells the command
+parent (MuxCommand, usually) to setup caller correctly. They use
+self.account to make sure to always use the account object rather than
+self.caller (which change depending on the level you are calling from)
+The property self.character can be used to access the character when
+these commands are triggered with a connected character (such as the
+case of the `ooc` command), it is None if we are OOC.
+
+Note that under MULTISESSION_MODE > 2, Account commands should use
+self.msg() and similar methods to reroute returns to the correct
+method. Otherwise all text will be returned to all connected sessions.
+
+"""
+importtime
+fromcodecsimportlookupascodecs_lookup
+fromdjango.confimportsettings
+fromevennia.server.sessionhandlerimportSESSIONS
+fromevennia.utilsimportutils,create,logger,search
+
+COMMAND_DEFAULT_CLASS=utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+_MAX_NR_CHARACTERS=settings.MAX_NR_CHARACTERS
+_MULTISESSION_MODE=settings.MULTISESSION_MODE
+
+# limit symbol import for API
+__all__=(
+ "CmdOOCLook",
+ "CmdIC",
+ "CmdOOC",
+ "CmdPassword",
+ "CmdQuit",
+ "CmdCharCreate",
+ "CmdOption",
+ "CmdSessions",
+ "CmdWho",
+ "CmdColorTest",
+ "CmdQuell",
+ "CmdCharDelete",
+ "CmdStyle",
+)
+
+
+classMuxAccountLookCommand(COMMAND_DEFAULT_CLASS):
+ """
+ Custom parent (only) parsing for OOC looking, sets a "playable"
+ property on the command based on the parsing.
+
+ """
+
+ defparse(self):
+ """Custom parsing"""
+
+ super().parse()
+
+ if_MULTISESSION_MODE<2:
+ # only one character allowed - not used in this mode
+ self.playable=None
+ return
+
+ playable=self.account.db._playable_characters
+ ifplayableisnotNone:
+ # clean up list if character object was deleted in between
+ ifNoneinplayable:
+ playable=[characterforcharacterinplayableifcharacter]
+ self.account.db._playable_characters=playable
+ # store playable property
+ ifself.args:
+ self.playable=dict((utils.to_str(char.key.lower()),char)forcharinplayable).get(
+ self.args.lower(),None
+ )
+ else:
+ self.playable=playable
+
+
+# Obs - these are all intended to be stored on the Account, and as such,
+# use self.account instead of self.caller, just to be sure. Also self.msg()
+# is used to make sure returns go to the right session
+
+# note that this is inheriting from MuxAccountLookCommand,
+# and has the .playable property.
+
[docs]classCmdOOCLook(MuxAccountLookCommand):
+ """
+ look while out-of-character
+
+ Usage:
+ look
+
+ Look in the ooc state.
+ """
+
+ # This is an OOC version of the look command. Since a
+ # Account doesn't have an in-game existence, there is no
+ # concept of location or "self". If we are controlling
+ # a character, pass control over to normal look.
+
+ key="look"
+ aliases=["l","ls"]
+ locks="cmd:all()"
+ help_category="General"
+
+ # this is used by the parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """implement the ooc look command"""
+
+ if_MULTISESSION_MODE<2:
+ # only one character allowed
+ self.msg("You are out-of-character (OOC).\nUse |wic|n to get back into the game.")
+ return
+
+ # call on-account look helper method
+ self.msg(self.account.at_look(target=self.playable,session=self.session))
+
+
+
[docs]classCmdCharCreate(COMMAND_DEFAULT_CLASS):
+ """
+ create a new character
+
+ Usage:
+ charcreate <charname> [= desc]
+
+ Create a new character, optionally giving it a description. You
+ may use upper-case letters in the name - you will nevertheless
+ always be able to access your character using lower-case letters
+ if you want.
+ """
+
+ key="charcreate"
+ locks="cmd:pperm(Player)"
+ help_category="General"
+
+ # this is used by the parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """create the new character"""
+ account=self.account
+ ifnotself.args:
+ self.msg("Usage: charcreate <charname> [= description]")
+ return
+ key=self.lhs
+ desc=self.rhs
+
+ charmax=_MAX_NR_CHARACTERS
+
+ ifnotaccount.is_superuserand(
+ account.db._playable_charactersandlen(account.db._playable_characters)>=charmax
+ ):
+ plural=""ifcharmax==1else"s"
+ self.msg(f"You may only create a maximum of {charmax} character{plural}.")
+ return
+ fromevennia.objects.modelsimportObjectDB
+
+ typeclass=settings.BASE_CHARACTER_TYPECLASS
+
+ ifObjectDB.objects.filter(db_typeclass_path=typeclass,db_key__iexact=key):
+ # check if this Character already exists. Note that we are only
+ # searching the base character typeclass here, not any child
+ # classes.
+ self.msg("|rA character named '|w%s|r' already exists.|n"%key)
+ return
+
+ # create the character
+ start_location=ObjectDB.objects.get_id(settings.START_LOCATION)
+ default_home=ObjectDB.objects.get_id(settings.DEFAULT_HOME)
+ permissions=settings.PERMISSION_ACCOUNT_DEFAULT
+ new_character=create.create_object(
+ typeclass,key=key,location=start_location,home=default_home,permissions=permissions
+ )
+ # only allow creator (and developers) to puppet this char
+ new_character.locks.add(
+ "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or perm(Admin)"
+ %(new_character.id,account.id,account.id)
+ )
+ account.db._playable_characters.append(new_character)
+ ifdesc:
+ new_character.db.desc=desc
+ elifnotnew_character.db.desc:
+ new_character.db.desc="This is a character."
+ self.msg(
+ "Created new character %s. Use |wic %s|n to enter the game as this character."
+ %(new_character.key,new_character.key)
+ )
+ logger.log_sec(
+ "Character Created: %s (Caller: %s, IP: %s)."
+ %(new_character,account,self.session.address)
+ )
+
+
+
[docs]classCmdCharDelete(COMMAND_DEFAULT_CLASS):
+ """
+ delete a character - this cannot be undone!
+
+ Usage:
+ chardelete <charname>
+
+ Permanently deletes one of your characters.
+ """
+
+ key="chardelete"
+ locks="cmd:pperm(Player)"
+ help_category="General"
+
+
[docs]deffunc(self):
+ """delete the character"""
+ account=self.account
+
+ ifnotself.args:
+ self.msg("Usage: chardelete <charactername>")
+ return
+
+ # use the playable_characters list to search
+ match=[
+ char
+ forcharinutils.make_iter(account.db._playable_characters)
+ ifchar.key.lower()==self.args.lower()
+ ]
+ ifnotmatch:
+ self.msg("You have no such character to delete.")
+ return
+ eliflen(match)>1:
+ self.msg(
+ "Aborting - there are two characters with the same name. Ask an admin to delete the right one."
+ )
+ return
+ else:# one match
+ fromevennia.utils.evmenuimportget_input
+
+ def_callback(caller,callback_prompt,result):
+ ifresult.lower()=="yes":
+ # only take action
+ delobj=caller.ndb._char_to_delete
+ key=delobj.key
+ caller.db._playable_characters=[
+ pcforpcincaller.db._playable_charactersifpc!=delobj
+ ]
+ delobj.delete()
+ self.msg("Character '%s' was permanently deleted."%key)
+ logger.log_sec(
+ "Character Deleted: %s (Caller: %s, IP: %s)."
+ %(key,account,self.session.address)
+ )
+ else:
+ self.msg("Deletion was aborted.")
+ delcaller.ndb._char_to_delete
+
+ match=match[0]
+ account.ndb._char_to_delete=match
+
+ # Return if caller has no permission to delete this
+ ifnotmatch.access(account,"delete"):
+ self.msg("You do not have permission to delete this character.")
+ return
+
+ prompt=(
+ "|rThis will permanently destroy '%s'. This cannot be undone.|n Continue yes/[no]?"
+ )
+ get_input(account,prompt%match.key,_callback)
+
+
+
[docs]classCmdIC(COMMAND_DEFAULT_CLASS):
+ """
+ control an object you have permission to puppet
+
+ Usage:
+ ic <character>
+
+ Go in-character (IC) as a given Character.
+
+ This will attempt to "become" a different object assuming you have
+ the right to do so. Note that it's the ACCOUNT character that puppets
+ characters/objects and which needs to have the correct permission!
+
+ You cannot become an object that is already controlled by another
+ account. In principle <character> can be any in-game object as long
+ as you the account have access right to puppet it.
+ """
+
+ key="ic"
+ # lock must be all() for different puppeted objects to access it.
+ locks="cmd:all()"
+ aliases="puppet"
+ help_category="General"
+
+ # this is used by the parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """
+ Main puppet method
+ """
+ account=self.account
+ session=self.session
+
+ new_character=None
+ character_candidates=[]
+
+ ifnotself.args:
+ character_candidates=[account.db._last_puppet]ifaccount.db._last_puppetelse[]
+ ifnotcharacter_candidates:
+ self.msg("Usage: ic <character>")
+ return
+ else:
+ # argument given
+
+ ifaccount.db._playable_characters:
+ # look at the playable_characters list first
+ character_candidates.extend(
+ account.search(
+ self.args,
+ candidates=account.db._playable_characters,
+ search_object=True,
+ quiet=True,
+ )
+ )
+
+ ifaccount.locks.check_lockstring(account,"perm(Builder)"):
+ # builders and higher should be able to puppet more than their
+ # playable characters.
+ ifsession.puppet:
+ # start by local search - this helps to avoid the user
+ # getting locked into their playable characters should one
+ # happen to be named the same as another. We replace the suggestion
+ # from playable_characters here - this allows builders to puppet objects
+ # with the same name as their playable chars should it be necessary
+ # (by going to the same location).
+ character_candidates=[
+ char
+ forcharinsession.puppet.search(self.args,quiet=True)
+ ifchar.access(account,"puppet")
+ ]
+ ifnotcharacter_candidates:
+ # fall back to global search only if Builder+ has no
+ # playable_characers in list and is not standing in a room
+ # with a matching char.
+ character_candidates.extend(
+ [
+ char
+ forcharinsearch.object_search(self.args)
+ ifchar.access(account,"puppet")
+ ]
+ )
+
+ # handle possible candidates
+ ifnotcharacter_candidates:
+ self.msg("That is not a valid character choice.")
+ return
+ iflen(character_candidates)>1:
+ self.msg(
+ "Multiple targets with the same name:\n%s"
+ %", ".join("%s(#%s)"%(obj.key,obj.id)forobjincharacter_candidates)
+ )
+ return
+ else:
+ new_character=character_candidates[0]
+
+ # do the puppet puppet
+ try:
+ account.puppet_object(session,new_character)
+ account.db._last_puppet=new_character
+ logger.log_sec(
+ "Puppet Success: (Caller: %s, Target: %s, IP: %s)."
+ %(account,new_character,self.session.address)
+ )
+ exceptRuntimeErrorasexc:
+ self.msg("|rYou cannot become |C%s|n: %s"%(new_character.name,exc))
+ logger.log_sec(
+ "Puppet Failed: %s (Caller: %s, Target: %s, IP: %s)."
+ %(exc,account,new_character,self.session.address)
+ )
+
+
+# note that this is inheriting from MuxAccountLookCommand,
+# and as such has the .playable property.
+
[docs]classCmdOOC(MuxAccountLookCommand):
+ """
+ stop puppeting and go ooc
+
+ Usage:
+ ooc
+
+ Go out-of-character (OOC).
+
+ This will leave your current character and put you in a incorporeal OOC state.
+ """
+
+ key="ooc"
+ locks="cmd:pperm(Player)"
+ aliases="unpuppet"
+ help_category="General"
+
+ # this is used by the parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """Implement function"""
+
+ account=self.account
+ session=self.session
+
+ old_char=account.get_puppet(session)
+ ifnotold_char:
+ string="You are already OOC."
+ self.msg(string)
+ return
+
+ account.db._last_puppet=old_char
+
+ # disconnect
+ try:
+ account.unpuppet_object(session)
+ self.msg("\n|GYou go OOC.|n\n")
+
+ if_MULTISESSION_MODE<2:
+ # only one character allowed
+ self.msg("You are out-of-character (OOC).\nUse |wic|n to get back into the game.")
+ return
+
+ self.msg(account.at_look(target=self.playable,session=session))
+
+ exceptRuntimeErrorasexc:
+ self.msg("|rCould not unpuppet from |c%s|n: %s"%(old_char,exc))
+
+
+
[docs]classCmdSessions(COMMAND_DEFAULT_CLASS):
+ """
+ check your connected session(s)
+
+ Usage:
+ sessions
+
+ Lists the sessions currently connected to your account.
+
+ """
+
+ key="sessions"
+ locks="cmd:all()"
+ help_category="General"
+
+ # this is used by the parent
+ account_caller=True
+
+
[docs]classCmdWho(COMMAND_DEFAULT_CLASS):
+ """
+ list who is currently online
+
+ Usage:
+ who
+ doing
+
+ Shows who is currently online. Doing is an alias that limits info
+ also for those with all permissions.
+ """
+
+ key="who"
+ aliases="doing"
+ locks="cmd:all()"
+
+ # this is used by the parent
+ account_caller=True
+
+
[docs]classCmdOption(COMMAND_DEFAULT_CLASS):
+ """
+ Set an account option
+
+ Usage:
+ option[/save] [name = value]
+
+ Switches:
+ save - Save the current option settings for future logins.
+ clear - Clear the saved options.
+
+ This command allows for viewing and setting client interface
+ settings. Note that saved options may not be able to be used if
+ later connecting with a client with different capabilities.
+
+
+ """
+
+ key="option"
+ aliases="options"
+ switch_options=("save","clear")
+ locks="cmd:all()"
+
+ # this is used by the parent
+ account_caller=True
+
+
[docs]classCmdPassword(COMMAND_DEFAULT_CLASS):
+ """
+ change your password
+
+ Usage:
+ password <old password> = <new password>
+
+ Changes your password. Make sure to pick a safe one.
+ """
+
+ key="password"
+ locks="cmd:pperm(Player)"
+
+ # this is used by the parent
+ account_caller=True
+
+
[docs]classCmdQuit(COMMAND_DEFAULT_CLASS):
+ """
+ quit the game
+
+ Usage:
+ quit
+
+ Switch:
+ all - disconnect all connected sessions
+
+ Gracefully disconnect your current session from the
+ game. Use the /all switch to disconnect from all sessions.
+ """
+
+ key="quit"
+ switch_options=("all",)
+ locks="cmd:all()"
+
+ # this is used by the parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """hook function"""
+ account=self.account
+
+ if"all"inself.switches:
+ account.msg(
+ "|RQuitting|n all sessions. Hope to see you soon again.",session=self.session
+ )
+ reason="quit/all"
+ forsessioninaccount.sessions.all():
+ account.disconnect_session_from_account(session,reason)
+ else:
+ nsess=len(account.sessions.all())
+ reason="quit"
+ ifnsess==2:
+ account.msg("|RQuitting|n. One session is still connected.",session=self.session)
+ elifnsess>2:
+ account.msg(
+ "|RQuitting|n. %i sessions are still connected."%(nsess-1),
+ session=self.session,
+ )
+ else:
+ # we are quitting the last available session
+ account.msg("|RQuitting|n. Hope to see you again, soon.",session=self.session)
+ account.disconnect_session_from_account(self.session,reason)
+
+
+
[docs]classCmdColorTest(COMMAND_DEFAULT_CLASS):
+ """
+ testing which colors your client support
+
+ Usage:
+ color ansi||xterm256
+
+ Prints a color map along with in-mud color codes to use to produce
+ them. It also tests what is supported in your client. Choices are
+ 16-color ansi (supported in most muds) or the 256-color xterm256
+ standard. No checking is done to determine your client supports
+ color - if not you will see rubbish appear.
+ """
+
+ key="color"
+ locks="cmd:all()"
+ help_category="General"
+
+ # this is used by the parent
+ account_caller=True
+
+ # the slices of the ANSI_PARSER lists to use for retrieving the
+ # relevant color tags to display. Replace if using another schema.
+ # This command can only show one set of markup.
+ slice_bright_fg=slice(7,15)# from ANSI_PARSER.ansi_map
+ slice_dark_fg=slice(15,23)# from ANSI_PARSER.ansi_map
+ slice_dark_bg=slice(-8,None)# from ANSI_PARSER.ansi_map
+ slice_bright_bg=slice(None,None)# from ANSI_PARSER.ansi_xterm256_bright_bg_map
+
+
[docs]deftable_format(self,table):
+ """
+ Helper method to format the ansi/xterm256 tables.
+ Takes a table of columns [[val,val,...],[val,val,...],...]
+ """
+ ifnottable:
+ return[[]]
+
+ extra_space=1
+ max_widths=[max([len(str(val))forvalincol])forcolintable]
+ ftable=[]
+ forirowinrange(len(table[0])):
+ ftable.append(
+ [
+ str(col[irow]).ljust(max_widths[icol])+" "*extra_space
+ foricol,colinenumerate(table)
+ ]
+ )
+ returnftable
[docs]classCmdQuell(COMMAND_DEFAULT_CLASS):
+ """
+ use character's permissions instead of account's
+
+ Usage:
+ quell
+ unquell
+
+ Normally the permission level of the Account is used when puppeting a
+ Character/Object to determine access. This command will switch the lock
+ system to make use of the puppeted Object's permissions instead. This is
+ useful mainly for testing.
+ Hierarchical permission quelling only work downwards, thus an Account cannot
+ use a higher-permission Character to escalate their permission level.
+ Use the unquell command to revert back to normal operation.
+ """
+
+ key="quell"
+ aliases=["unquell"]
+ locks="cmd:pperm(Player)"
+ help_category="General"
+
+ # this is used by the parent
+ account_caller=True
+
+ def_recache_locks(self,account):
+ """Helper method to reset the lockhandler on an already puppeted object"""
+ ifself.session:
+ char=self.session.puppet
+ ifchar:
+ # we are already puppeting an object. We need to reset
+ # the lock caches (otherwise the superuser status change
+ # won't be visible until repuppet)
+ char.locks.reset()
+ account.locks.reset()
+
+
[docs]deffunc(self):
+ """Perform the command"""
+ account=self.account
+ permstr=(
+ account.is_superuser
+ and" (superuser)"
+ or"(%s)"%(", ".join(account.permissions.all()))
+ )
+ ifself.cmdstringin("unquell","unquell"):
+ ifnotaccount.attributes.get("_quell"):
+ self.msg("Already using normal Account permissions %s."%permstr)
+ else:
+ account.attributes.remove("_quell")
+ self.msg("Account permissions %s restored."%permstr)
+ else:
+ ifaccount.attributes.get("_quell"):
+ self.msg("Already quelling Account %s permissions."%permstr)
+ return
+ account.attributes.add("_quell",True)
+ puppet=self.session.puppetifself.sessionelseNone
+ ifpuppet:
+ cpermstr="(%s)"%", ".join(puppet.permissions.all())
+ cpermstr="Quelling to current puppet's permissions %s."%cpermstr
+ cpermstr+=(
+ "\n(Note: If this is higher than Account permissions %s,"
+ " the lowest of the two will be used.)"%permstr
+ )
+ cpermstr+="\nUse unquell to return to normal permission usage."
+ self.msg(cpermstr)
+ else:
+ self.msg("Quelling Account permissions%s. Use unquell to get them back."%permstr)
+ self._recache_locks(account)
+
+
+
[docs]classCmdStyle(COMMAND_DEFAULT_CLASS):
+ """
+ In-game style options
+
+ Usage:
+ style
+ style <option> = <value>
+
+ Configure stylings for in-game display elements like table borders, help
+ entriest etc. Use without arguments to see all available options.
+
+ """
+
+ key="style"
+ switch_options=["clear"]
+
+
[docs]classCmdBoot(COMMAND_DEFAULT_CLASS):
+ """
+ kick an account from the server.
+
+ Usage
+ boot[/switches] <account obj> [: reason]
+
+ Switches:
+ quiet - Silently boot without informing account
+ sid - boot by session id instead of name or dbref
+
+ Boot an account object from the server. If a reason is
+ supplied it will be echoed to the user unless /quiet is set.
+ """
+
+ key="boot"
+ switch_options=("quiet","sid")
+ locks="cmd:perm(boot) or perm(Admin)"
+ help_category="Admin"
+
+
[docs]deffunc(self):
+ """Implementing the function"""
+ caller=self.caller
+ args=self.args
+
+ ifnotargs:
+ caller.msg("Usage: boot[/switches] <account> [:reason]")
+ return
+
+ if":"inargs:
+ args,reason=[a.strip()forainargs.split(":",1)]
+ else:
+ args,reason=args,""
+
+ boot_list=[]
+
+ if"sid"inself.switches:
+ # Boot a particular session id.
+ sessions=SESSIONS.get_sessions(True)
+ forsessinsessions:
+ # Find the session with the matching session id.
+ ifsess.sessid==int(args):
+ boot_list.append(sess)
+ break
+ else:
+ # Boot by account object
+ pobj=search.account_search(args)
+ ifnotpobj:
+ caller.msg("Account %s was not found."%args)
+ return
+ pobj=pobj[0]
+ ifnotpobj.access(caller,"boot"):
+ string="You don't have the permission to boot %s."%(pobj.key,)
+ caller.msg(string)
+ return
+ # we have a bootable object with a connected user
+ matches=SESSIONS.sessions_from_account(pobj)
+ formatchinmatches:
+ boot_list.append(match)
+
+ ifnotboot_list:
+ caller.msg("No matching sessions found. The Account does not seem to be online.")
+ return
+
+ # Carry out the booting of the sessions in the boot list.
+
+ feedback=None
+ if"quiet"notinself.switches:
+ feedback="You have been disconnected by %s.\n"%caller.name
+ ifreason:
+ feedback+="\nReason given: %s"%reason
+
+ forsessioninboot_list:
+ session.msg(feedback)
+ session.account.disconnect_session_from_account(session)
+
+ ifpobjandboot_list:
+ logger.log_sec(
+ "Booted: %s (Reason: %s, Caller: %s, IP: %s)."
+ %(pobj,reason,caller,self.session.address)
+ )
+
+
+# regex matching IP addresses with wildcards, eg. 233.122.4.*
+IPREGEX=re.compile(r"[0-9*]{1,3}\.[0-9*]{1,3}\.[0-9*]{1,3}\.[0-9*]{1,3}")
+
+
+deflist_bans(cmd,banlist):
+ """
+ Helper function to display a list of active bans. Input argument
+ is the banlist read into the two commands ban and unban below.
+
+ Args:
+ cmd (Command): Instance of the Ban command.
+ banlist (list): List of bans to list.
+ """
+ ifnotbanlist:
+ return"No active bans were found."
+
+ table=cmd.styled_table("|wid","|wname/ip","|wdate","|wreason")
+ forinum,baninenumerate(banlist):
+ table.add_row(str(inum+1),ban[0]andban[0]orban[1],ban[3],ban[4])
+ return"|wActive bans:|n\n%s"%table
+
+
+
[docs]classCmdBan(COMMAND_DEFAULT_CLASS):
+ """
+ ban an account from the server
+
+ Usage:
+ ban [<name or ip> [: reason]]
+
+ Without any arguments, shows numbered list of active bans.
+
+ This command bans a user from accessing the game. Supply an optional
+ reason to be able to later remember why the ban was put in place.
+
+ It is often preferable to ban an account from the server than to
+ delete an account with accounts/delete. If banned by name, that account
+ account can no longer be logged into.
+
+ IP (Internet Protocol) address banning allows blocking all access
+ from a specific address or subnet. Use an asterisk (*) as a
+ wildcard.
+
+ Examples:
+ ban thomas - ban account 'thomas'
+ ban/ip 134.233.2.111 - ban specific ip address
+ ban/ip 134.233.2.* - ban all in a subnet
+ ban/ip 134.233.*.* - even wider ban
+
+ A single IP filter can be easy to circumvent by changing computers
+ or requesting a new IP address. Setting a wide IP block filter with
+ wildcards might be tempting, but remember that it may also
+ accidentally block innocent users connecting from the same country
+ or region.
+
+ """
+
+ key="ban"
+ aliases=["bans"]
+ locks="cmd:perm(ban) or perm(Developer)"
+ help_category="Admin"
+
+
[docs]deffunc(self):
+ """
+ Bans are stored in a serverconf db object as a list of
+ dictionaries:
+ [ (name, ip, ipregex, date, reason),
+ (name, ip, ipregex, date, reason),... ]
+ where name and ip are set by the user and are shown in
+ lists. ipregex is a converted form of ip where the * is
+ replaced by an appropriate regex pattern for fast
+ matching. date is the time stamp the ban was instigated and
+ 'reason' is any optional info given to the command. Unset
+ values in each tuple is set to the empty string.
+ """
+ banlist=ServerConfig.objects.conf("server_bans")
+ ifnotbanlist:
+ banlist=[]
+
+ ifnotself.argsor(
+ self.switchesandnotany(switchin("ip","name")forswitchinself.switches)
+ ):
+ self.caller.msg(list_bans(self,banlist))
+ return
+
+ now=time.ctime()
+ reason=""
+ if":"inself.args:
+ ban,reason=self.args.rsplit(":",1)
+ else:
+ ban=self.args
+ ban=ban.lower()
+ ipban=IPREGEX.findall(ban)
+ ifnotipban:
+ # store as name
+ typ="Name"
+ bantup=(ban,"","",now,reason)
+ else:
+ # an ip address.
+ typ="IP"
+ ban=ipban[0]
+ # replace * with regex form and compile it
+ ipregex=ban.replace(".","\.")
+ ipregex=ipregex.replace("*","[0-9]{1,3}")
+ ipregex=re.compile(r"%s"%ipregex)
+ bantup=("",ban,ipregex,now,reason)
+ # save updated banlist
+ banlist.append(bantup)
+ ServerConfig.objects.conf("server_bans",banlist)
+ self.caller.msg("%s-Ban |w%s|n was added."%(typ,ban))
+ logger.log_sec(
+ "Banned %s: %s (Caller: %s, IP: %s)."
+ %(typ,ban.strip(),self.caller,self.session.address)
+ )
+
+
+
[docs]classCmdUnban(COMMAND_DEFAULT_CLASS):
+ """
+ remove a ban from an account
+
+ Usage:
+ unban <banid>
+
+ This will clear an account name/ip ban previously set with the ban
+ command. Use this command without an argument to view a numbered
+ list of bans. Use the numbers in this list to select which one to
+ unban.
+
+ """
+
+ key="unban"
+ locks="cmd:perm(unban) or perm(Developer)"
+ help_category="Admin"
+
+
[docs]deffunc(self):
+ """Implement unbanning"""
+
+ banlist=ServerConfig.objects.conf("server_bans")
+
+ ifnotself.args:
+ self.caller.msg(list_bans(self,banlist))
+ return
+
+ try:
+ num=int(self.args)
+ exceptException:
+ self.caller.msg("You must supply a valid ban id to clear.")
+ return
+
+ ifnotbanlist:
+ self.caller.msg("There are no bans to clear.")
+ elifnot(0<num<len(banlist)+1):
+ self.caller.msg("Ban id |w%s|x was not found."%self.args)
+ else:
+ # all is ok, clear ban
+ ban=banlist[num-1]
+ delbanlist[num-1]
+ ServerConfig.objects.conf("server_bans",banlist)
+ value=" ".join([sforsinban[:2]])
+ self.caller.msg("Cleared ban %s: %s"%(num,value))
+ logger.log_sec(
+ "Unbanned: %s (Caller: %s, IP: %s)."
+ %(value.strip(),self.caller,self.session.address)
+ )
+
+
+
[docs]classCmdEmit(COMMAND_DEFAULT_CLASS):
+ """
+ admin command for emitting message to multiple objects
+
+ Usage:
+ emit[/switches] [<obj>, <obj>, ... =] <message>
+ remit [<obj>, <obj>, ... =] <message>
+ pemit [<obj>, <obj>, ... =] <message>
+
+ Switches:
+ room - limit emits to rooms only (default)
+ accounts - limit emits to accounts only
+ contents - send to the contents of matched objects too
+
+ Emits a message to the selected objects or to
+ your immediate surroundings. If the object is a room,
+ send to its contents. remit and pemit are just
+ limited forms of emit, for sending to rooms and
+ to accounts respectively.
+ """
+
+ key="emit"
+ aliases=["pemit","remit"]
+ switch_options=("room","accounts","contents")
+ locks="cmd:perm(emit) or perm(Builder)"
+ help_category="Admin"
+
+
[docs]deffunc(self):
+ """Implement the command"""
+
+ caller=self.caller
+ args=self.args
+
+ ifnotargs:
+ string="Usage: "
+ string+="\nemit[/switches] [<obj>, <obj>, ... =] <message>"
+ string+="\nremit [<obj>, <obj>, ... =] <message>"
+ string+="\npemit [<obj>, <obj>, ... =] <message>"
+ caller.msg(string)
+ return
+
+ rooms_only="rooms"inself.switches
+ accounts_only="accounts"inself.switches
+ send_to_contents="contents"inself.switches
+
+ # we check which command was used to force the switches
+ ifself.cmdstring=="remit":
+ rooms_only=True
+ send_to_contents=True
+ elifself.cmdstring=="pemit":
+ accounts_only=True
+
+ ifnotself.rhs:
+ message=self.args
+ objnames=[caller.location.key]
+ else:
+ message=self.rhs
+ objnames=self.lhslist
+
+ # send to all objects
+ forobjnameinobjnames:
+ obj=caller.search(objname,global_search=True)
+ ifnotobj:
+ return
+ ifrooms_onlyandobj.locationisnotNone:
+ caller.msg("%s is not a room. Ignored."%objname)
+ continue
+ ifaccounts_onlyandnotobj.has_account:
+ caller.msg("%s has no active account. Ignored."%objname)
+ continue
+ ifobj.access(caller,"tell"):
+ obj.msg(message)
+ ifsend_to_contentsandhasattr(obj,"msg_contents"):
+ obj.msg_contents(message)
+ caller.msg("Emitted to %s and contents:\n%s"%(objname,message))
+ else:
+ caller.msg("Emitted to %s:\n%s"%(objname,message))
+ else:
+ caller.msg("You are not allowed to emit to %s."%objname)
+
+
+
[docs]classCmdNewPassword(COMMAND_DEFAULT_CLASS):
+ """
+ change the password of an account
+
+ Usage:
+ userpassword <user obj> = <new password>
+
+ Set an account's password.
+ """
+
+ key="userpassword"
+ locks="cmd:perm(newpassword) or perm(Admin)"
+ help_category="Admin"
+
+
[docs]deffunc(self):
+ """Implement the function."""
+
+ caller=self.caller
+
+ ifnotself.rhs:
+ self.msg("Usage: userpassword <user obj> = <new password>")
+ return
+
+ # the account search also matches 'me' etc.
+ account=caller.search_account(self.lhs)
+ ifnotaccount:
+ return
+
+ newpass=self.rhs
+
+ # Validate password
+ validated,error=account.validate_password(newpass)
+ ifnotvalidated:
+ errors=[eforsuberrorinerror.messagesforeinerror.messages]
+ string="\n".join(errors)
+ caller.msg(string)
+ return
+
+ account.set_password(newpass)
+ account.save()
+ self.msg("%s - new password set to '%s'."%(account.name,newpass))
+ ifaccount.character!=caller:
+ account.msg("%s has changed your password to '%s'."%(caller.name,newpass))
+ logger.log_sec(
+ "Password Changed: %s (Caller: %s, IP: %s)."%(account,caller,self.session.address)
+ )
+
+
+
[docs]classCmdPerm(COMMAND_DEFAULT_CLASS):
+ """
+ set the permissions of an account/object
+
+ Usage:
+ perm[/switch] <object> [= <permission>[,<permission>,...]]
+ perm[/switch] *<account> [= <permission>[,<permission>,...]]
+
+ Switches:
+ del - delete the given permission from <object> or <account>.
+ account - set permission on an account (same as adding * to name)
+
+ This command sets/clears individual permission strings on an object
+ or account. If no permission is given, list all permissions on <object>.
+ """
+
+ key="perm"
+ aliases="setperm"
+ switch_options=("del","account")
+ locks="cmd:perm(perm) or perm(Developer)"
+ help_category="Admin"
+
+
[docs]deffunc(self):
+ """Implement function"""
+
+ caller=self.caller
+ switches=self.switches
+ lhs,rhs=self.lhs,self.rhs
+
+ ifnotself.args:
+ string="Usage: perm[/switch] object [ = permission, permission, ...]"
+ caller.msg(string)
+ return
+
+ accountmode="account"inself.switchesorlhs.startswith("*")
+ lhs=lhs.lstrip("*")
+
+ ifaccountmode:
+ obj=caller.search_account(lhs)
+ else:
+ obj=caller.search(lhs,global_search=True)
+ ifnotobj:
+ return
+
+ ifnotrhs:
+ ifnotobj.access(caller,"examine"):
+ caller.msg("You are not allowed to examine this object.")
+ return
+
+ string="Permissions on |w%s|n: "%obj.key
+ ifnotobj.permissions.all():
+ string+="<None>"
+ else:
+ string+=", ".join(obj.permissions.all())
+ if(
+ hasattr(obj,"account")
+ andhasattr(obj.account,"is_superuser")
+ andobj.account.is_superuser
+ ):
+ string+="\n(... but this object is currently controlled by a SUPERUSER! "
+ string+="All access checks are passed automatically.)"
+ caller.msg(string)
+ return
+
+ # we supplied an argument on the form obj = perm
+ locktype="edit"ifaccountmodeelse"control"
+ ifnotobj.access(caller,locktype):
+ caller.msg(
+ "You are not allowed to edit this %s's permissions."
+ %("account"ifaccountmodeelse"object")
+ )
+ return
+
+ caller_result=[]
+ target_result=[]
+ if"del"inswitches:
+ # delete the given permission(s) from object.
+ forperminself.rhslist:
+ obj.permissions.remove(perm)
+ ifobj.permissions.get(perm):
+ caller_result.append(
+ "\nPermissions %s could not be removed from %s."%(perm,obj.name)
+ )
+ else:
+ caller_result.append(
+ "\nPermission %s removed from %s (if they existed)."%(perm,obj.name)
+ )
+ target_result.append(
+ "\n%s revokes the permission(s) %s from you."%(caller.name,perm)
+ )
+ logger.log_sec(
+ "Permissions Deleted: %s, %s (Caller: %s, IP: %s)."
+ %(perm,obj,caller,self.session.address)
+ )
+ else:
+ # add a new permission
+ permissions=obj.permissions.all()
+
+ forperminself.rhslist:
+
+ # don't allow to set a permission higher in the hierarchy than
+ # the one the caller has (to prevent self-escalation)
+ ifperm.lower()inPERMISSION_HIERARCHYandnotobj.locks.check_lockstring(
+ caller,"dummy:perm(%s)"%perm
+ ):
+ caller.msg(
+ "You cannot assign a permission higher than the one you have yourself."
+ )
+ return
+
+ ifperminpermissions:
+ caller_result.append(
+ "\nPermission '%s' is already defined on %s."%(perm,obj.name)
+ )
+ else:
+ obj.permissions.add(perm)
+ plystring="the Account"ifaccountmodeelse"the Object/Character"
+ caller_result.append(
+ "\nPermission '%s' given to %s (%s)."%(perm,obj.name,plystring)
+ )
+ target_result.append(
+ "\n%s gives you (%s, %s) the permission '%s'."
+ %(caller.name,obj.name,plystring,perm)
+ )
+ logger.log_sec(
+ "Permissions Added: %s, %s (Caller: %s, IP: %s)."
+ %(obj,perm,caller,self.session.address)
+ )
+
+ caller.msg("".join(caller_result).strip())
+ iftarget_result:
+ obj.msg("".join(target_result).strip())
+
+
+
[docs]classCmdWall(COMMAND_DEFAULT_CLASS):
+ """
+ make an announcement to all
+
+ Usage:
+ wall <message>
+
+ Announces a message to all connected sessions
+ including all currently unlogged in.
+ """
+
+ key="wall"
+ locks="cmd:perm(wall) or perm(Admin)"
+ help_category="Admin"
+
+
[docs]classCmdForce(COMMAND_DEFAULT_CLASS):
+ """
+ forces an object to execute a command
+
+ Usage:
+ force <object>=<command string>
+
+ Example:
+ force bob=get stick
+ """
+
+ key="force"
+ locks="cmd:perm(spawn) or perm(Builder)"
+ help_category="Building"
+ perm_used="edit"
+
+
[docs]deffunc(self):
+ """Implements the force command"""
+ ifnotself.lhsornotself.rhs:
+ self.caller.msg("You must provide a target and a command string to execute.")
+ return
+ targ=self.caller.search(self.lhs)
+ ifnottarg:
+ return
+ ifnottarg.access(self.caller,self.perm_used):
+ self.caller.msg("You don't have permission to force them to execute commands.")
+ return
+ targ.execute_cmd(self.rhs)
+ self.caller.msg("You have forced %s to: %s"%(targ,self.rhs))
Source code for evennia.commands.default.batchprocess
+"""
+Batch processors
+
+These commands implements the 'batch-command' and 'batch-code'
+processors, using the functionality in evennia.utils.batchprocessors.
+They allow for offline world-building.
+
+Batch-command is the simpler system. This reads a file (*.ev)
+containing a list of in-game commands and executes them in sequence as
+if they had been entered in the game (including permission checks
+etc).
+
+Batch-code is a full-fledged python code interpreter that reads blocks
+of python code (*.py) and executes them in sequence. This allows for
+much more power than Batch-command, but requires knowing Python and
+the Evennia API. It is also a severe security risk and should
+therefore always be limited to superusers only.
+
+"""
+importre
+
+fromdjango.confimportsettings
+fromevennia.utils.batchprocessorsimportBATCHCMD,BATCHCODE
+fromevennia.commands.cmdsetimportCmdSet
+fromevennia.utilsimportlogger,utils
+
+
+_RE_COMMENT=re.compile(r"^#.*?$",re.MULTILINE+re.DOTALL)
+_RE_CODE_START=re.compile(r"^# batchcode code:",re.MULTILINE)
+_COMMAND_DEFAULT_CLASS=utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# limit symbols for API inclusion
+__all__=("CmdBatchCommands","CmdBatchCode")
+
+_HEADER_WIDTH=70
+_UTF8_ERROR="""
+ |rDecode error in '%s'.|n
+
+ This file contains non-ascii character(s). This is common if you
+ wrote some input in a language that has more letters and special
+ symbols than English; such as accents or umlauts. This is usually
+ fine and fully supported! But for Evennia to know how to decode such
+ characters in a universal way, the batchfile must be saved with the
+ international 'UTF-8' encoding. This file is not.
+
+ Please re-save the batchfile with the UTF-8 encoding (refer to the
+ documentation of your text editor on how to do this, or switch to a
+ better featured one) and try again.
+
+ Error reported was: '%s'
+"""
+
+
+# -------------------------------------------------------------
+# Helper functions
+# -------------------------------------------------------------
+
+
+defformat_header(caller,entry):
+ """
+ Formats a header
+ """
+ width=_HEADER_WIDTH-10
+ # strip all comments for the header
+ ifcaller.ndb.batch_batchmode!="batch_commands":
+ # only do cleanup for batchcode
+ entry=_RE_CODE_START.split(entry,1)[1]
+ entry=_RE_COMMENT.sub("",entry).strip()
+ header=utils.crop(entry,width=width)
+ ptr=caller.ndb.batch_stackptr+1
+ stacklen=len(caller.ndb.batch_stack)
+ header="|w%02i/%02i|G: %s|n"%(ptr,stacklen,header)
+ # add extra space to the side for padding.
+ header="%s%s"%(header," "*(width-len(header)))
+ header=header.replace("\n","\\n")
+
+ returnheader
+
+
+defformat_code(entry):
+ """
+ Formats the viewing of code and errors
+ """
+ code=""
+ forlineinentry.split("\n"):
+ code+="\n|G>>>|n %s"%line
+ returncode.strip()
+
+
+defbatch_cmd_exec(caller):
+ """
+ Helper function for executing a single batch-command entry
+ """
+ ptr=caller.ndb.batch_stackptr
+ stack=caller.ndb.batch_stack
+ command=stack[ptr]
+ caller.msg(format_header(caller,command))
+ try:
+ caller.execute_cmd(command)
+ exceptException:
+ logger.log_trace()
+ returnFalse
+ returnTrue
+
+
+defbatch_code_exec(caller):
+ """
+ Helper function for executing a single batch-code entry
+ """
+ ptr=caller.ndb.batch_stackptr
+ stack=caller.ndb.batch_stack
+ debug=caller.ndb.batch_debug
+ code=stack[ptr]
+
+ caller.msg(format_header(caller,code))
+ err=BATCHCODE.code_exec(code,extra_environ={"caller":caller},debug=debug)
+ iferr:
+ caller.msg(format_code(err))
+ returnFalse
+ returnTrue
+
+
+defstep_pointer(caller,step=1):
+ """
+ Step in stack, returning the item located.
+
+ stackptr - current position in stack
+ stack - the stack of units
+ step - how many steps to move from stackptr
+ """
+ ptr=caller.ndb.batch_stackptr
+ stack=caller.ndb.batch_stack
+ nstack=len(stack)
+ ifptr+step<=0:
+ caller.msg("|RBeginning of batch file.")
+ ifptr+step>=nstack:
+ caller.msg("|REnd of batch file.")
+ caller.ndb.batch_stackptr=max(0,min(nstack-1,ptr+step))
+
+
+defshow_curr(caller,showall=False):
+ """
+ Show the current position in stack
+ """
+ stackptr=caller.ndb.batch_stackptr
+ stack=caller.ndb.batch_stack
+
+ ifstackptr>=len(stack):
+ caller.ndb.batch_stackptr=len(stack)-1
+ show_curr(caller,showall)
+ return
+
+ entry=stack[stackptr]
+
+ string=format_header(caller,entry)
+ codeall=entry.strip()
+ string+="|G(hh for help)"
+ ifshowall:
+ forlineincodeall.split("\n"):
+ string+="\n|G||n %s"%line
+ caller.msg(string)
+
+
+defpurge_processor(caller):
+ """
+ This purges all effects running
+ on the caller.
+ """
+ try:
+ delcaller.ndb.batch_stack
+ delcaller.ndb.batch_stackptr
+ delcaller.ndb.batch_pythonpath
+ delcaller.ndb.batch_batchmode
+ exceptException:
+ # something might have already been erased; it's not critical
+ pass
+ # clear everything back to the state before the batch call
+ ifcaller.ndb.batch_cmdset_backup:
+ caller.cmdset.cmdset_stack=caller.ndb.batch_cmdset_backup
+ caller.cmdset.update()
+ delcaller.ndb.batch_cmdset_backup
+ else:
+ # something went wrong. Purge cmdset except default
+ caller.cmdset.clear()
+
+ caller.scripts.validate()# this will purge interactive mode
+
+
+# -------------------------------------------------------------
+# main access commands
+# -------------------------------------------------------------
+
+
+
[docs]classCmdBatchCommands(_COMMAND_DEFAULT_CLASS):
+ """
+ build from batch-command file
+
+ Usage:
+ batchcommands[/interactive] <python.path.to.file>
+
+ Switch:
+ interactive - this mode will offer more control when
+ executing the batch file, like stepping,
+ skipping, reloading etc.
+
+ Runs batches of commands from a batch-cmd text file (*.ev).
+
+ """
+
+ key="batchcommands"
+ aliases=["batchcommand","batchcmd"]
+ switch_options=("interactive",)
+ locks="cmd:perm(batchcommands) or perm(Developer)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Starts the processor."""
+
+ caller=self.caller
+
+ args=self.args
+ ifnotargs:
+ caller.msg("Usage: batchcommands[/interactive] <path.to.file>")
+ return
+ python_path=self.args
+
+ # parse indata file
+
+ try:
+ commands=BATCHCMD.parse_file(python_path)
+ exceptUnicodeDecodeErroraserr:
+ caller.msg(_UTF8_ERROR%(python_path,err))
+ return
+ exceptIOErroraserr:
+ iferr:
+ err="{}\n".format(str(err))
+ else:
+ err=""
+ string=(
+ "%s'%s' could not load. You have to supply python paths "
+ "from one of the defined batch-file directories\n (%s)."
+ )
+ caller.msg(string%(err,python_path,", ".join(settings.BASE_BATCHPROCESS_PATHS)))
+ return
+ ifnotcommands:
+ caller.msg("File %s seems empty of valid commands."%python_path)
+ return
+
+ switches=self.switches
+
+ # Store work data in cache
+ caller.ndb.batch_stack=commands
+ caller.ndb.batch_stackptr=0
+ caller.ndb.batch_pythonpath=python_path
+ caller.ndb.batch_batchmode="batch_commands"
+ # we use list() here to create a new copy of the cmdset stack
+ caller.ndb.batch_cmdset_backup=list(caller.cmdset.cmdset_stack)
+ caller.cmdset.add(BatchSafeCmdSet)
+
+ if"inter"inswitchesor"interactive"inswitches:
+ # Allow more control over how batch file is executed
+
+ # Set interactive state directly
+ caller.cmdset.add(BatchInteractiveCmdSet)
+
+ caller.msg("\nBatch-command processor - Interactive mode for %s ..."%python_path)
+ show_curr(caller)
+ else:
+ caller.msg(
+ "Running Batch-command processor - Automatic mode "
+ "for %s (this might take some time) ..."%python_path
+ )
+
+ # run in-process (might block)
+ for_inrange(len(commands)):
+ # loop through the batch file
+ ifnotbatch_cmd_exec(caller):
+ return
+ step_pointer(caller,1)
+ # clean out the safety cmdset and clean out all other
+ # temporary attrs.
+ string=" Batchfile '%s' applied."%python_path
+ caller.msg("|G%s"%string)
+ purge_processor(caller)
+
+
+
[docs]classCmdBatchCode(_COMMAND_DEFAULT_CLASS):
+ """
+ build from batch-code file
+
+ Usage:
+ batchcode[/interactive] <python path to file>
+
+ Switch:
+ interactive - this mode will offer more control when
+ executing the batch file, like stepping,
+ skipping, reloading etc.
+ debug - auto-delete all objects that has been marked as
+ deletable in the script file (see example files for
+ syntax). This is useful so as to to not leave multiple
+ object copies behind when testing out the script.
+
+ Runs batches of commands from a batch-code text file (*.py).
+
+ """
+
+ key="batchcode"
+ aliases=["batchcodes"]
+ switch_options=("interactive","debug")
+ locks="cmd:superuser()"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Starts the processor."""
+
+ caller=self.caller
+
+ args=self.args
+ ifnotargs:
+ caller.msg("Usage: batchcode[/interactive/debug] <path.to.file>")
+ return
+ python_path=self.args
+ debug="debug"inself.switches
+
+ # parse indata file
+ try:
+ codes=BATCHCODE.parse_file(python_path)
+ exceptUnicodeDecodeErroraserr:
+ caller.msg(_UTF8_ERROR%(python_path,err))
+ return
+ exceptIOErroraserr:
+ iferr:
+ err="{}\n".format(str(err))
+ else:
+ err=""
+ string=(
+ "%s'%s' could not load. You have to supply python paths "
+ "from one of the defined batch-file directories\n (%s)."
+ )
+ caller.msg(string%(err,python_path,", ".join(settings.BASE_BATCHPROCESS_PATHS)))
+ return
+ ifnotcodes:
+ caller.msg("File %s seems empty of functional code."%python_path)
+ return
+
+ switches=self.switches
+
+ # Store work data in cache
+ caller.ndb.batch_stack=codes
+ caller.ndb.batch_stackptr=0
+ caller.ndb.batch_pythonpath=python_path
+ caller.ndb.batch_batchmode="batch_code"
+ caller.ndb.batch_debug=debug
+ # we use list() here to create a new copy of cmdset_stack
+ caller.ndb.batch_cmdset_backup=list(caller.cmdset.cmdset_stack)
+ caller.cmdset.add(BatchSafeCmdSet)
+
+ if"inter"inswitchesor"interactive"inswitches:
+ # Allow more control over how batch file is executed
+
+ # Set interactive state directly
+ caller.cmdset.add(BatchInteractiveCmdSet)
+
+ caller.msg("\nBatch-code processor - Interactive mode for %s ..."%python_path)
+ show_curr(caller)
+ else:
+ caller.msg("Running Batch-code processor - Automatic mode for %s ..."%python_path)
+
+ for_inrange(len(codes)):
+ # loop through the batch file
+ ifnotbatch_code_exec(caller):
+ return
+ step_pointer(caller,1)
+ # clean out the safety cmdset and clean out all other
+ # temporary attrs.
+ string=" Batchfile '%s' applied."%python_path
+ caller.msg("|G%s"%string)
+ purge_processor(caller)
+
+
+# -------------------------------------------------------------
+# State-commands for the interactive batch processor modes
+# (these are the same for both processors)
+# -------------------------------------------------------------
+
+
+classCmdStateAbort(_COMMAND_DEFAULT_CLASS):
+ """
+ abort
+
+ This is a safety feature. It force-ejects us out of the processor and to
+ the default cmdset, regardless of what current cmdset the processor might
+ have put us in (e.g. when testing buggy scripts etc).
+ """
+
+ key="abort"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ """Exit back to default."""
+ purge_processor(self.caller)
+ self.caller.msg("Exited processor and reset out active cmdset back to the default one.")
+
+
+classCmdStateLL(_COMMAND_DEFAULT_CLASS):
+ """
+ ll
+
+ Look at the full source for the current
+ command definition.
+ """
+
+ key="ll"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ show_curr(self.caller,showall=True)
+
+
+classCmdStatePP(_COMMAND_DEFAULT_CLASS):
+ """
+ pp
+
+ Process the currently shown command definition.
+ """
+
+ key="pp"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ """
+ This checks which type of processor we are running.
+ """
+ caller=self.caller
+ ifcaller.ndb.batch_batchmode=="batch_code":
+ batch_code_exec(caller)
+ else:
+ batch_cmd_exec(caller)
+
+
+classCmdStateRR(_COMMAND_DEFAULT_CLASS):
+ """
+ rr
+
+ Reload the batch file, keeping the current
+ position in it.
+ """
+
+ key="rr"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ ifcaller.ndb.batch_batchmode=="batch_code":
+ new_data=BATCHCODE.parse_file(caller.ndb.batch_pythonpath)
+ else:
+ new_data=BATCHCMD.parse_file(caller.ndb.batch_pythonpath)
+ caller.ndb.batch_stack=new_data
+ caller.msg(format_code("File reloaded. Staying on same command."))
+ show_curr(caller)
+
+
+classCmdStateRRR(_COMMAND_DEFAULT_CLASS):
+ """
+ rrr
+
+ Reload the batch file, starting over
+ from the beginning.
+ """
+
+ key="rrr"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ ifcaller.ndb.batch_batchmode=="batch_code":
+ BATCHCODE.parse_file(caller.ndb.batch_pythonpath)
+ else:
+ BATCHCMD.parse_file(caller.ndb.batch_pythonpath)
+ caller.ndb.batch_stackptr=0
+ caller.msg(format_code("File reloaded. Restarting from top."))
+ show_curr(caller)
+
+
+classCmdStateNN(_COMMAND_DEFAULT_CLASS):
+ """
+ nn
+
+ Go to next command. No commands are executed.
+ """
+
+ key="nn"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ arg=self.args
+ ifargandarg.isdigit():
+ step=int(self.args)
+ else:
+ step=1
+ step_pointer(caller,step)
+ show_curr(caller)
+
+
+classCmdStateNL(_COMMAND_DEFAULT_CLASS):
+ """
+ nl
+
+ Go to next command, viewing its full source.
+ No commands are executed.
+ """
+
+ key="nl"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ arg=self.args
+ ifargandarg.isdigit():
+ step=int(self.args)
+ else:
+ step=1
+ step_pointer(caller,step)
+ show_curr(caller,showall=True)
+
+
+classCmdStateBB(_COMMAND_DEFAULT_CLASS):
+ """
+ bb
+
+ Backwards to previous command. No commands
+ are executed.
+ """
+
+ key="bb"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ arg=self.args
+ ifargandarg.isdigit():
+ step=-int(self.args)
+ else:
+ step=-1
+ step_pointer(caller,step)
+ show_curr(caller)
+
+
+classCmdStateBL(_COMMAND_DEFAULT_CLASS):
+ """
+ bl
+
+ Backwards to previous command, viewing its full
+ source. No commands are executed.
+ """
+
+ key="bl"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ arg=self.args
+ ifargandarg.isdigit():
+ step=-int(self.args)
+ else:
+ step=-1
+ step_pointer(caller,step)
+ show_curr(caller,showall=True)
+
+
+classCmdStateSS(_COMMAND_DEFAULT_CLASS):
+ """
+ ss [steps]
+
+ Process current command, then step to the next
+ one. If steps is given,
+ process this many commands.
+ """
+
+ key="ss"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ arg=self.args
+ ifargandarg.isdigit():
+ step=int(self.args)
+ else:
+ step=1
+
+ for_inrange(step):
+ ifcaller.ndb.batch_batchmode=="batch_code":
+ batch_code_exec(caller)
+ else:
+ batch_cmd_exec(caller)
+ step_pointer(caller,1)
+ show_curr(caller)
+
+
+classCmdStateSL(_COMMAND_DEFAULT_CLASS):
+ """
+ sl [steps]
+
+ Process current command, then step to the next
+ one, viewing its full source. If steps is given,
+ process this many commands.
+ """
+
+ key="sl"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ arg=self.args
+ ifargandarg.isdigit():
+ step=int(self.args)
+ else:
+ step=1
+
+ for_inrange(step):
+ ifcaller.ndb.batch_batchmode=="batch_code":
+ batch_code_exec(caller)
+ else:
+ batch_cmd_exec(caller)
+ step_pointer(caller,1)
+ show_curr(caller)
+
+
+classCmdStateCC(_COMMAND_DEFAULT_CLASS):
+ """
+ cc
+
+ Continue to process all remaining
+ commands.
+ """
+
+ key="cc"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ nstack=len(caller.ndb.batch_stack)
+ ptr=caller.ndb.batch_stackptr
+ step=nstack-ptr
+
+ for_inrange(step):
+ ifcaller.ndb.batch_batchmode=="batch_code":
+ batch_code_exec(caller)
+ else:
+ batch_cmd_exec(caller)
+ step_pointer(caller,1)
+ show_curr(caller)
+
+ purge_processor(self)
+ caller.msg(format_code("Finished processing batch file."))
+
+
+classCmdStateJJ(_COMMAND_DEFAULT_CLASS):
+ """
+ jj <command number>
+
+ Jump to specific command number
+ """
+
+ key="jj"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ arg=self.args
+ ifargandarg.isdigit():
+ number=int(self.args)-1
+ else:
+ caller.msg(format_code("You must give a number index."))
+ return
+ ptr=caller.ndb.batch_stackptr
+ step=number-ptr
+ step_pointer(caller,step)
+ show_curr(caller)
+
+
+classCmdStateJL(_COMMAND_DEFAULT_CLASS):
+ """
+ jl <command number>
+
+ Jump to specific command number and view its full source.
+ """
+
+ key="jl"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ caller=self.caller
+ arg=self.args
+ ifargandarg.isdigit():
+ number=int(self.args)-1
+ else:
+ caller.msg(format_code("You must give a number index."))
+ return
+ ptr=caller.ndb.batch_stackptr
+ step=number-ptr
+ step_pointer(caller,step)
+ show_curr(caller,showall=True)
+
+
+classCmdStateQQ(_COMMAND_DEFAULT_CLASS):
+ """
+ qq
+
+ Quit the batchprocessor.
+ """
+
+ key="qq"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ purge_processor(self.caller)
+ self.caller.msg("Aborted interactive batch mode.")
+
+
+classCmdStateHH(_COMMAND_DEFAULT_CLASS):
+ """Help command"""
+
+ key="hh"
+ help_category="BatchProcess"
+ locks="cmd:perm(batchcommands)"
+
+ deffunc(self):
+ string="""
+ Interactive batch processing commands:
+
+ nn [steps] - next command (no processing)
+ nl [steps] - next & look
+ bb [steps] - back to previous command (no processing)
+ bl [steps] - back & look
+ jj <N> - jump to command nr N (no processing)
+ jl <N> - jump & look
+ pp - process currently shown command (no step)
+ ss [steps] - process & step
+ sl [steps] - process & step & look
+ ll - look at full definition of current command
+ rr - reload batch file (stay on current)
+ rrr - reload batch file (start from first)
+ hh - this help list
+
+ cc - continue processing to end, then quit.
+ qq - quit (abort all remaining commands)
+
+ abort - this is a safety command that always is available
+ regardless of what cmdsets gets added to us during
+ batch-command processing. It immediately shuts down
+ the processor and returns us to the default cmdset.
+ """
+ self.caller.msg(string)
+
+
+# -------------------------------------------------------------
+#
+# Defining the cmdsets for the interactive batchprocessor
+# mode (same for both processors)
+#
+# -------------------------------------------------------------
+
+
+classBatchSafeCmdSet(CmdSet):
+ """
+ The base cmdset for the batch processor.
+ This sets a 'safe' abort command that will
+ always be available to get out of everything.
+ """
+
+ key="Batch_default"
+ priority=150# override other cmdsets.
+
+ defat_cmdset_creation(self):
+ """Init the cmdset"""
+ self.add(CmdStateAbort())
+
+
+classBatchInteractiveCmdSet(CmdSet):
+ """
+ The cmdset for the interactive batch processor mode.
+ """
+
+ key="Batch_interactive"
+ priority=104
+
+ defat_cmdset_creation(self):
+ """init the cmdset"""
+ self.add(CmdStateAbort())
+ self.add(CmdStateLL())
+ self.add(CmdStatePP())
+ self.add(CmdStateRR())
+ self.add(CmdStateRRR())
+ self.add(CmdStateNN())
+ self.add(CmdStateNL())
+ self.add(CmdStateBB())
+ self.add(CmdStateBL())
+ self.add(CmdStateSS())
+ self.add(CmdStateSL())
+ self.add(CmdStateCC())
+ self.add(CmdStateJJ())
+ self.add(CmdStateJL())
+ self.add(CmdStateQQ())
+ self.add(CmdStateHH())
+
[docs]classObjManipCommand(COMMAND_DEFAULT_CLASS):
+ """
+ This is a parent class for some of the defining objmanip commands
+ since they tend to have some more variables to define new objects.
+
+ Each object definition can have several components. First is
+ always a name, followed by an optional alias list and finally an
+ some optional data, such as a typeclass or a location. A comma ','
+ separates different objects. Like this:
+
+ name1;alias;alias;alias:option, name2;alias;alias ...
+
+ Spaces between all components are stripped.
+
+ A second situation is attribute manipulation. Such commands
+ are simpler and offer combinations
+
+ objname/attr/attr/attr, objname/attr, ...
+
+ """
+
+ # OBS - this is just a parent - it's not intended to actually be
+ # included in a commandset on its own!
+
+
[docs]defparse(self):
+ """
+ We need to expand the default parsing to get all
+ the cases, see the module doc.
+ """
+ # get all the normal parsing done (switches etc)
+ super().parse()
+
+ obj_defs=([],[])# stores left- and right-hand side of '='
+ obj_attrs=([],[])# "
+
+ foriside,arglistinenumerate((self.lhslist,self.rhslist)):
+ # lhslist/rhslist is already split by ',' at this point
+ forobjdefinarglist:
+ aliases,option,attrs=[],None,[]
+ if":"inobjdef:
+ objdef,option=[part.strip()forpartinobjdef.rsplit(":",1)]
+ if";"inobjdef:
+ objdef,aliases=[part.strip()forpartinobjdef.split(";",1)]
+ aliases=[alias.strip()foraliasinaliases.split(";")ifalias.strip()]
+ if"/"inobjdef:
+ objdef,attrs=[part.strip()forpartinobjdef.split("/",1)]
+ attrs=[part.strip().lower()forpartinattrs.split("/")ifpart.strip()]
+ # store data
+ obj_defs[iside].append({"name":objdef,"option":option,"aliases":aliases})
+ obj_attrs[iside].append({"name":objdef,"attrs":attrs})
+
+ # store for future access
+ self.lhs_objs=obj_defs[0]
+ self.rhs_objs=obj_defs[1]
+ self.lhs_objattr=obj_attrs[0]
+ self.rhs_objattr=obj_attrs[1]
+
+
+
[docs]classCmdSetObjAlias(COMMAND_DEFAULT_CLASS):
+ """
+ adding permanent aliases for object
+
+ Usage:
+ alias <obj> [= [alias[,alias,alias,...]]]
+ alias <obj> =
+ alias/category <obj> = [alias[,alias,...]:<category>
+
+ Switches:
+ category - requires ending input with :category, to store the
+ given aliases with the given category.
+
+ Assigns aliases to an object so it can be referenced by more
+ than one name. Assign empty to remove all aliases from object. If
+ assigning a category, all aliases given will be using this category.
+
+ Observe that this is not the same thing as personal aliases
+ created with the 'nick' command! Aliases set with alias are
+ changing the object in question, making those aliases usable
+ by everyone.
+ """
+
+ key="alias"
+ aliases="setobjalias"
+ switch_options=("category",)
+ locks="cmd:perm(setobjalias) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Set the aliases."""
+
+ caller=self.caller
+
+ ifnotself.lhs:
+ string="Usage: alias <obj> [= [alias[,alias ...]]]"
+ self.caller.msg(string)
+ return
+ objname=self.lhs
+
+ # Find the object to receive aliases
+ obj=caller.search(objname)
+ ifnotobj:
+ return
+ ifself.rhsisNone:
+ # no =, so we just list aliases on object.
+ aliases=obj.aliases.all(return_key_and_category=True)
+ ifaliases:
+ caller.msg(
+ "Aliases for %s: %s"
+ %(
+ obj.get_display_name(caller),
+ ", ".join(
+ "'%s'%s"
+ %(alias,""ifcategoryisNoneelse"[category:'%s']"%category)
+ for(alias,category)inaliases
+ ),
+ )
+ )
+ else:
+ caller.msg("No aliases exist for '%s'."%obj.get_display_name(caller))
+ return
+
+ ifnot(obj.access(caller,"control")orobj.access(caller,"edit")):
+ caller.msg("You don't have permission to do that.")
+ return
+
+ ifnotself.rhs:
+ # we have given an empty =, so delete aliases
+ old_aliases=obj.aliases.all()
+ ifold_aliases:
+ caller.msg(
+ "Cleared aliases from %s: %s"
+ %(obj.get_display_name(caller),", ".join(old_aliases))
+ )
+ obj.aliases.clear()
+ else:
+ caller.msg("No aliases to clear.")
+ return
+
+ category=None
+ if"category"inself.switches:
+ if":"inself.rhs:
+ rhs,category=self.rhs.rsplit(":",1)
+ category=category.strip()
+ else:
+ caller.msg(
+ "If specifying the /category switch, the category must be given "
+ "as :category at the end."
+ )
+ else:
+ rhs=self.rhs
+
+ # merge the old and new aliases (if any)
+ old_aliases=obj.aliases.get(category=category,return_list=True)
+ new_aliases=[alias.strip().lower()foraliasinrhs.split(",")ifalias.strip()]
+
+ # make the aliases only appear once
+ old_aliases.extend(new_aliases)
+ aliases=list(set(old_aliases))
+
+ # save back to object.
+ obj.aliases.add(aliases,category=category)
+
+ # we need to trigger this here, since this will force
+ # (default) Exits to rebuild their Exit commands with the new
+ # aliases
+ obj.at_cmdset_get(force_init=True)
+
+ # report all aliases on the object
+ caller.msg(
+ "Alias(es) for '%s' set to '%s'%s."
+ %(
+ obj.get_display_name(caller),
+ str(obj.aliases),
+ " (category: '%s')"%categoryifcategoryelse"",
+ )
+ )
+
+
+
[docs]classCmdCopy(ObjManipCommand):
+ """
+ copy an object and its properties
+
+ Usage:
+ copy <original obj> [= <new_name>][;alias;alias..]
+ [:<new_location>] [,<new_name2> ...]
+
+ Create one or more copies of an object. If you don't supply any targets,
+ one exact copy of the original object will be created with the name *_copy.
+ """
+
+ key="copy"
+ locks="cmd:perm(copy) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Uses ObjManipCommand.parse()"""
+
+ caller=self.caller
+ args=self.args
+ ifnotargs:
+ caller.msg(
+ "Usage: copy <obj> [=<new_name>[;alias;alias..]]"
+ "[:<new_location>] [, <new_name2>...]"
+ )
+ return
+
+ ifnotself.rhs:
+ # this has no target =, so an identical new object is created.
+ from_obj_name=self.args
+ from_obj=caller.search(from_obj_name)
+ ifnotfrom_obj:
+ return
+ to_obj_name="%s_copy"%from_obj_name
+ to_obj_aliases=["%s_copy"%aliasforaliasinfrom_obj.aliases.all()]
+ copiedobj=ObjectDB.objects.copy_object(
+ from_obj,new_key=to_obj_name,new_aliases=to_obj_aliases
+ )
+ ifcopiedobj:
+ string="Identical copy of %s, named '%s' was created."%(
+ from_obj_name,
+ to_obj_name,
+ )
+ else:
+ string="There was an error copying %s."
+ else:
+ # we have specified =. This might mean many object targets
+ from_obj_name=self.lhs_objs[0]["name"]
+ from_obj=caller.search(from_obj_name)
+ ifnotfrom_obj:
+ return
+ forobjdefinself.rhs_objs:
+ # loop through all possible copy-to targets
+ to_obj_name=objdef["name"]
+ to_obj_aliases=objdef["aliases"]
+ to_obj_location=objdef["option"]
+ ifto_obj_location:
+ to_obj_location=caller.search(to_obj_location,global_search=True)
+ ifnotto_obj_location:
+ return
+
+ copiedobj=ObjectDB.objects.copy_object(
+ from_obj,
+ new_key=to_obj_name,
+ new_location=to_obj_location,
+ new_aliases=to_obj_aliases,
+ )
+ ifcopiedobj:
+ string="Copied %s to '%s' (aliases: %s)."%(
+ from_obj_name,
+ to_obj_name,
+ to_obj_aliases,
+ )
+ else:
+ string="There was an error copying %s to '%s'."%(from_obj_name,to_obj_name)
+ # we are done, echo to user
+ caller.msg(string)
+
+
+
[docs]classCmdCpAttr(ObjManipCommand):
+ """
+ copy attributes between objects
+
+ Usage:
+ cpattr[/switch] <obj>/<attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...]
+ cpattr[/switch] <obj>/<attr> = <obj1> [,<obj2>,<obj3>,...]
+ cpattr[/switch] <attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...]
+ cpattr[/switch] <attr> = <obj1>[,<obj2>,<obj3>,...]
+
+ Switches:
+ move - delete the attribute from the source object after copying.
+
+ Example:
+ cpattr coolness = Anna/chillout, Anna/nicety, Tom/nicety
+ ->
+ copies the coolness attribute (defined on yourself), to attributes
+ on Anna and Tom.
+
+ Copy the attribute one object to one or more attributes on another object.
+ If you don't supply a source object, yourself is used.
+ """
+
+ key="cpattr"
+ switch_options=("move",)
+ locks="cmd:perm(cpattr) or perm(Builder)"
+ help_category="Building"
+
+
[docs]defcheck_from_attr(self,obj,attr,clear=False):
+ """
+ Hook for overriding on subclassed commands. Checks to make sure a
+ caller can copy the attr from the object in question. If not, return a
+ false value and the command will abort. An error message should be
+ provided by this function.
+
+ If clear is True, user is attempting to move the attribute.
+ """
+ returnTrue
+
+
[docs]defcheck_to_attr(self,obj,attr):
+ """
+ Hook for overriding on subclassed commands. Checks to make sure a
+ caller can write to the specified attribute on the specified object.
+ If not, return a false value and the attribute will be skipped. An
+ error message should be provided by this function.
+ """
+ returnTrue
+
+
[docs]defcheck_has_attr(self,obj,attr):
+ """
+ Hook for overriding on subclassed commands. Do any preprocessing
+ required and verify an object has an attribute.
+ """
+ ifnotobj.attributes.has(attr):
+ self.caller.msg("%s doesn't have an attribute %s."%(obj.name,attr))
+ returnFalse
+ returnTrue
+
+
[docs]defget_attr(self,obj,attr):
+ """
+ Hook for overriding on subclassed commands. Do any preprocessing
+ required and get the attribute from the object.
+ """
+ returnobj.attributes.get(attr)
+
+
[docs]deffunc(self):
+ """
+ Do the copying.
+ """
+ caller=self.caller
+
+ ifnotself.rhs:
+ string="""Usage:
+ cpattr[/switch] <obj>/<attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...]
+ cpattr[/switch] <obj>/<attr> = <obj1> [,<obj2>,<obj3>,...]
+ cpattr[/switch] <attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...]
+ cpattr[/switch] <attr> = <obj1>[,<obj2>,<obj3>,...]"""
+ caller.msg(string)
+ return
+
+ lhs_objattr=self.lhs_objattr
+ to_objs=self.rhs_objattr
+ from_obj_name=lhs_objattr[0]["name"]
+ from_obj_attrs=lhs_objattr[0]["attrs"]
+
+ ifnotfrom_obj_attrs:
+ # this means the from_obj_name is actually an attribute
+ # name on self.
+ from_obj_attrs=[from_obj_name]
+ from_obj=self.caller
+ else:
+ from_obj=caller.search(from_obj_name)
+ ifnotfrom_objornotto_objs:
+ caller.msg("You have to supply both source object and target(s).")
+ return
+ # copy to all to_obj:ects
+ if"move"inself.switches:
+ clear=True
+ else:
+ clear=False
+ ifnotself.check_from_attr(from_obj,from_obj_attrs[0],clear=clear):
+ return
+
+ forattrinfrom_obj_attrs:
+ ifnotself.check_has_attr(from_obj,attr):
+ return
+
+ if(len(from_obj_attrs)!=len(set(from_obj_attrs)))andclear:
+ self.caller.msg("|RCannot have duplicate source names when moving!")
+ return
+
+ result=[]
+
+ forto_objinto_objs:
+ to_obj_name=to_obj["name"]
+ to_obj_attrs=to_obj["attrs"]
+ to_obj=caller.search(to_obj_name)
+ ifnotto_obj:
+ result.append("\nCould not find object '%s'"%to_obj_name)
+ continue
+ forinum,from_attrinenumerate(from_obj_attrs):
+ try:
+ to_attr=to_obj_attrs[inum]
+ exceptIndexError:
+ # if there are too few attributes given
+ # on the to_obj, we copy the original name instead.
+ to_attr=from_attr
+ ifnotself.check_to_attr(to_obj,to_attr):
+ continue
+ value=self.get_attr(from_obj,from_attr)
+ to_obj.attributes.add(to_attr,value)
+ ifclearandnot(from_obj==to_objandfrom_attr==to_attr):
+ from_obj.attributes.remove(from_attr)
+ result.append(
+ "\nMoved %s.%s -> %s.%s. (value: %s)"
+ %(from_obj.name,from_attr,to_obj_name,to_attr,repr(value))
+ )
+ else:
+ result.append(
+ "\nCopied %s.%s -> %s.%s. (value: %s)"
+ %(from_obj.name,from_attr,to_obj_name,to_attr,repr(value))
+ )
+ caller.msg("".join(result))
+
+
+
[docs]classCmdMvAttr(ObjManipCommand):
+ """
+ move attributes between objects
+
+ Usage:
+ mvattr[/switch] <obj>/<attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...]
+ mvattr[/switch] <obj>/<attr> = <obj1> [,<obj2>,<obj3>,...]
+ mvattr[/switch] <attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...]
+ mvattr[/switch] <attr> = <obj1>[,<obj2>,<obj3>,...]
+
+ Switches:
+ copy - Don't delete the original after moving.
+
+ Move an attribute from one object to one or more attributes on another
+ object. If you don't supply a source object, yourself is used.
+ """
+
+ key="mvattr"
+ switch_options=("copy",)
+ locks="cmd:perm(mvattr) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """
+ Do the moving
+ """
+ ifnotself.rhs:
+ string="""Usage:
+ mvattr[/switch] <obj>/<attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...]
+ mvattr[/switch] <obj>/<attr> = <obj1> [,<obj2>,<obj3>,...]
+ mvattr[/switch] <attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...]
+ mvattr[/switch] <attr> = <obj1>[,<obj2>,<obj3>,...]"""
+ self.caller.msg(string)
+ return
+
+ # simply use cpattr for all the functionality
+ if"copy"inself.switches:
+ self.execute_cmd("cpattr %s"%self.args)
+ else:
+ self.execute_cmd("cpattr/move %s"%self.args)
+
+
+
[docs]classCmdCreate(ObjManipCommand):
+ """
+ create new objects
+
+ Usage:
+ create[/drop] <objname>[;alias;alias...][:typeclass], <objname>...
+
+ switch:
+ drop - automatically drop the new object into your current
+ location (this is not echoed). This also sets the new
+ object's home to the current location rather than to you.
+
+ Creates one or more new objects. If typeclass is given, the object
+ is created as a child of this typeclass. The typeclass script is
+ assumed to be located under types/ and any further
+ directory structure is given in Python notation. So if you have a
+ correct typeclass 'RedButton' defined in
+ types/examples/red_button.py, you could create a new
+ object of this type like this:
+
+ create/drop button;red : examples.red_button.RedButton
+
+ """
+
+ key="create"
+ switch_options=("drop",)
+ locks="cmd:perm(create) or perm(Builder)"
+ help_category="Building"
+
+ # lockstring of newly created objects, for easy overloading.
+ # Will be formatted with the {id} of the creating object.
+ new_obj_lockstring="control:id({id}) or perm(Admin);delete:id({id}) or perm(Admin)"
+
+
[docs]deffunc(self):
+ """
+ Creates the object.
+ """
+
+ caller=self.caller
+
+ ifnotself.args:
+ string="Usage: create[/drop] <newname>[;alias;alias...] [:typeclass.path]"
+ caller.msg(string)
+ return
+
+ # create the objects
+ forobjdefinself.lhs_objs:
+ string=""
+ name=objdef["name"]
+ aliases=objdef["aliases"]
+ typeclass=objdef["option"]
+
+ # create object (if not a valid typeclass, the default
+ # object typeclass will automatically be used)
+ lockstring=self.new_obj_lockstring.format(id=caller.id)
+ obj=create.create_object(
+ typeclass,
+ name,
+ caller,
+ home=caller,
+ aliases=aliases,
+ locks=lockstring,
+ report_to=caller,
+ )
+ ifnotobj:
+ continue
+ ifaliases:
+ string="You create a new %s: %s (aliases: %s)."
+ string=string%(obj.typename,obj.name,", ".join(aliases))
+ else:
+ string="You create a new %s: %s."
+ string=string%(obj.typename,obj.name)
+ # set a default desc
+ ifnotobj.db.desc:
+ obj.db.desc="You see nothing special."
+ if"drop"inself.switches:
+ ifcaller.location:
+ obj.home=caller.location
+ obj.move_to(caller.location,quiet=True)
+ ifstring:
+ caller.msg(string)
+
+
+def_desc_load(caller):
+ returncaller.db.evmenu_target.db.descor""
+
+
+def_desc_save(caller,buf):
+ """
+ Save line buffer to the desc prop. This should
+ return True if successful and also report its status to the user.
+ """
+ caller.db.evmenu_target.db.desc=buf
+ caller.msg("Saved.")
+ returnTrue
+
+
+def_desc_quit(caller):
+ caller.attributes.remove("evmenu_target")
+ caller.msg("Exited editor.")
+
+
+
[docs]classCmdDesc(COMMAND_DEFAULT_CLASS):
+ """
+ describe an object or the current room.
+
+ Usage:
+ desc [<obj> =] <description>
+
+ Switches:
+ edit - Open up a line editor for more advanced editing.
+
+ Sets the "desc" attribute on an object. If an object is not given,
+ describe the current room.
+ """
+
+ key="desc"
+ aliases="describe"
+ switch_options=("edit",)
+ locks="cmd:perm(desc) or perm(Builder)"
+ help_category="Building"
+
+
[docs]defedit_handler(self):
+ ifself.rhs:
+ self.msg("|rYou may specify a value, or use the edit switch, ""but not both.|n")
+ return
+ ifself.args:
+ obj=self.caller.search(self.args)
+ else:
+ obj=self.caller.locationorself.msg("|rYou can't describe oblivion.|n")
+ ifnotobj:
+ return
+
+ ifnot(obj.access(self.caller,"control")orobj.access(self.caller,"edit")):
+ self.caller.msg("You don't have permission to edit the description of %s."%obj.key)
+
+ self.caller.db.evmenu_target=obj
+ # launch the editor
+ EvEditor(
+ self.caller,
+ loadfunc=_desc_load,
+ savefunc=_desc_save,
+ quitfunc=_desc_quit,
+ key="desc",
+ persistent=True,
+ )
+ return
+
+
[docs]deffunc(self):
+ """Define command"""
+
+ caller=self.caller
+ ifnotself.argsand"edit"notinself.switches:
+ caller.msg("Usage: desc [<obj> =] <description>")
+ return
+
+ if"edit"inself.switches:
+ self.edit_handler()
+ return
+
+ if"="inself.args:
+ # We have an =
+ obj=caller.search(self.lhs)
+ ifnotobj:
+ return
+ desc=self.rhsor""
+ else:
+ obj=caller.locationorself.msg("|rYou can't describe oblivion.|n")
+ ifnotobj:
+ return
+ desc=self.args
+ ifobj.access(self.caller,"control")orobj.access(self.caller,"edit"):
+ obj.db.desc=desc
+ caller.msg("The description was set on %s."%obj.get_display_name(caller))
+ else:
+ caller.msg("You don't have permission to edit the description of %s."%obj.key)
+
+
+
[docs]classCmdDestroy(COMMAND_DEFAULT_CLASS):
+ """
+ permanently delete objects
+
+ Usage:
+ destroy[/switches] [obj, obj2, obj3, [dbref-dbref], ...]
+
+ Switches:
+ override - The destroy command will usually avoid accidentally
+ destroying account objects. This switch overrides this safety.
+ force - destroy without confirmation.
+ Examples:
+ destroy house, roof, door, 44-78
+ destroy 5-10, flower, 45
+ destroy/force north
+
+ Destroys one or many objects. If dbrefs are used, a range to delete can be
+ given, e.g. 4-10. Also the end points will be deleted. This command
+ displays a confirmation before destroying, to make sure of your choice.
+ You can specify the /force switch to bypass this confirmation.
+ """
+
+ key="destroy"
+ aliases=["delete","del"]
+ switch_options=("override","force")
+ locks="cmd:perm(destroy) or perm(Builder)"
+ help_category="Building"
+
+ confirm=True# set to False to always bypass confirmation
+ default_confirm="yes"# what to assume if just pressing enter (yes/no)
+
+
[docs]deffunc(self):
+ """Implements the command."""
+
+ caller=self.caller
+ delete=True
+
+ ifnotself.argsornotself.lhslist:
+ caller.msg("Usage: destroy[/switches] [obj, obj2, obj3, [dbref-dbref],...]")
+ delete=False
+
+ defdelobj(obj):
+ # helper function for deleting a single object
+ string=""
+ ifnotobj.pk:
+ string="\nObject %s was already deleted."%obj.db_key
+ else:
+ objname=obj.name
+ ifnot(obj.access(caller,"control")orobj.access(caller,"delete")):
+ return"\nYou don't have permission to delete %s."%objname
+ ifobj.accountand"override"notinself.switches:
+ return(
+ "\nObject %s is controlled by an active account. Use /override to delete anyway."
+ %objname
+ )
+ ifobj.dbid==int(settings.DEFAULT_HOME.lstrip("#")):
+ return(
+ "\nYou are trying to delete |c%s|n, which is set as DEFAULT_HOME. "
+ "Re-point settings.DEFAULT_HOME to another "
+ "object before continuing."%objname
+ )
+
+ had_exits=hasattr(obj,"exits")andobj.exits
+ had_objs=hasattr(obj,"contents")andany(
+ obj
+ forobjinobj.contents
+ ifnot(hasattr(obj,"exits")andobjnotinobj.exits)
+ )
+ # do the deletion
+ okay=obj.delete()
+ ifnotokay:
+ string+=(
+ "\nERROR: %s not deleted, probably because delete() returned False."
+ %objname
+ )
+ else:
+ string+="\n%s was destroyed."%objname
+ ifhad_exits:
+ string+=" Exits to and from %s were destroyed as well."%objname
+ ifhad_objs:
+ string+=" Objects inside %s were moved to their homes."%objname
+ returnstring
+
+ objs=[]
+ forobjnameinself.lhslist:
+ ifnotdelete:
+ continue
+
+ if"-"inobjname:
+ # might be a range of dbrefs
+ dmin,dmax=[utils.dbref(part,reqhash=False)forpartinobjname.split("-",1)]
+ ifdminanddmax:
+ fordbrefinrange(int(dmin),int(dmax+1)):
+ obj=caller.search("#"+str(dbref))
+ ifobj:
+ objs.append(obj)
+ continue
+ else:
+ obj=caller.search(objname)
+ else:
+ obj=caller.search(objname)
+
+ ifobjisNone:
+ self.caller.msg(
+ " (Objects to destroy must either be local or specified with a unique #dbref.)"
+ )
+ elifobjnotinobjs:
+ objs.append(obj)
+
+ ifobjsand("force"notinself.switchesandtype(self).confirm):
+ confirm="Are you sure you want to destroy "
+ iflen(objs)==1:
+ confirm+=objs[0].get_display_name(caller)
+ eliflen(objs)<5:
+ confirm+=", ".join([obj.get_display_name(caller)forobjinobjs])
+ else:
+ confirm+=", ".join(["#{}".format(obj.id)forobjinobjs])
+ confirm+=" [yes]/no?"ifself.default_confirm=="yes"else" yes/[no]"
+ answer=""
+ answer=yield(confirm)
+ answer=self.default_confirmifanswer==""elseanswer
+
+ ifanswerandanswernotin("yes","y","no","n"):
+ caller.msg(
+ "Canceled: Either accept the default by pressing return or specify yes/no."
+ )
+ delete=False
+ elifanswer.strip().lower()in("n","no"):
+ caller.msg("Canceled: No object was destroyed.")
+ delete=False
+
+ ifdelete:
+ results=[]
+ forobjinobjs:
+ results.append(delobj(obj))
+
+ ifresults:
+ caller.msg("".join(results).strip())
+
+
+
[docs]classCmdDig(ObjManipCommand):
+ """
+ build new rooms and connect them to the current location
+
+ Usage:
+ dig[/switches] <roomname>[;alias;alias...][:typeclass]
+ [= <exit_to_there>[;alias][:typeclass]]
+ [, <exit_to_here>[;alias][:typeclass]]
+
+ Switches:
+ tel or teleport - move yourself to the new room
+
+ Examples:
+ dig kitchen = north;n, south;s
+ dig house:myrooms.MyHouseTypeclass
+ dig sheer cliff;cliff;sheer = climb up, climb down
+
+ This command is a convenient way to build rooms quickly; it creates the
+ new room and you can optionally set up exits back and forth between your
+ current room and the new one. You can add as many aliases as you
+ like to the name of the room and the exits in question; an example
+ would be 'north;no;n'.
+ """
+
+ key="dig"
+ switch_options=("teleport",)
+ locks="cmd:perm(dig) or perm(Builder)"
+ help_category="Building"
+
+ # lockstring of newly created rooms, for easy overloading.
+ # Will be formatted with the {id} of the creating object.
+ new_room_lockstring=(
+ "control:id({id}) or perm(Admin); "
+ "delete:id({id}) or perm(Admin); "
+ "edit:id({id}) or perm(Admin)"
+ )
+
+
[docs]deffunc(self):
+ """Do the digging. Inherits variables from ObjManipCommand.parse()"""
+
+ caller=self.caller
+
+ ifnotself.lhs:
+ string="Usage: dig[/teleport] <roomname>[;alias;alias...]""[:parent] [= <exit_there>"
+ string+="[;alias;alias..][:parent]] "
+ string+="[, <exit_back_here>[;alias;alias..][:parent]]"
+ caller.msg(string)
+ return
+
+ room=self.lhs_objs[0]
+
+ ifnotroom["name"]:
+ caller.msg("You must supply a new room name.")
+ return
+ location=caller.location
+
+ # Create the new room
+ typeclass=room["option"]
+ ifnottypeclass:
+ typeclass=settings.BASE_ROOM_TYPECLASS
+
+ # create room
+ new_room=create.create_object(
+ typeclass,room["name"],aliases=room["aliases"],report_to=caller
+ )
+ lockstring=self.new_room_lockstring.format(id=caller.id)
+ new_room.locks.add(lockstring)
+ alias_string=""
+ ifnew_room.aliases.all():
+ alias_string=" (%s)"%", ".join(new_room.aliases.all())
+ room_string="Created room %s(%s)%s of type %s."%(
+ new_room,
+ new_room.dbref,
+ alias_string,
+ typeclass,
+ )
+
+ # create exit to room
+
+ exit_to_string=""
+ exit_back_string=""
+
+ ifself.rhs_objs:
+ to_exit=self.rhs_objs[0]
+ ifnotto_exit["name"]:
+ exit_to_string="\nNo exit created to new room."
+ elifnotlocation:
+ exit_to_string="\nYou cannot create an exit from a None-location."
+ else:
+ # Build the exit to the new room from the current one
+ typeclass=to_exit["option"]
+ ifnottypeclass:
+ typeclass=settings.BASE_EXIT_TYPECLASS
+
+ new_to_exit=create.create_object(
+ typeclass,
+ to_exit["name"],
+ location,
+ aliases=to_exit["aliases"],
+ locks=lockstring,
+ destination=new_room,
+ report_to=caller,
+ )
+ alias_string=""
+ ifnew_to_exit.aliases.all():
+ alias_string=" (%s)"%", ".join(new_to_exit.aliases.all())
+ exit_to_string="\nCreated Exit from %s to %s: %s(%s)%s."
+ exit_to_string=exit_to_string%(
+ location.name,
+ new_room.name,
+ new_to_exit,
+ new_to_exit.dbref,
+ alias_string,
+ )
+
+ # Create exit back from new room
+
+ iflen(self.rhs_objs)>1:
+ # Building the exit back to the current room
+ back_exit=self.rhs_objs[1]
+ ifnotback_exit["name"]:
+ exit_back_string="\nNo back exit created."
+ elifnotlocation:
+ exit_back_string="\nYou cannot create an exit back to a None-location."
+ else:
+ typeclass=back_exit["option"]
+ ifnottypeclass:
+ typeclass=settings.BASE_EXIT_TYPECLASS
+ new_back_exit=create.create_object(
+ typeclass,
+ back_exit["name"],
+ new_room,
+ aliases=back_exit["aliases"],
+ locks=lockstring,
+ destination=location,
+ report_to=caller,
+ )
+ alias_string=""
+ ifnew_back_exit.aliases.all():
+ alias_string=" (%s)"%", ".join(new_back_exit.aliases.all())
+ exit_back_string="\nCreated Exit back from %s to %s: %s(%s)%s."
+ exit_back_string=exit_back_string%(
+ new_room.name,
+ location.name,
+ new_back_exit,
+ new_back_exit.dbref,
+ alias_string,
+ )
+ caller.msg("%s%s%s"%(room_string,exit_to_string,exit_back_string))
+ ifnew_roomand"teleport"inself.switches:
+ caller.move_to(new_room)
+
+
+
[docs]classCmdTunnel(COMMAND_DEFAULT_CLASS):
+ """
+ create new rooms in cardinal directions only
+
+ Usage:
+ tunnel[/switch] <direction>[:typeclass] [= <roomname>[;alias;alias;...][:typeclass]]
+
+ Switches:
+ oneway - do not create an exit back to the current location
+ tel - teleport to the newly created room
+
+ Example:
+ tunnel n
+ tunnel n = house;mike's place;green building
+
+ This is a simple way to build using pre-defined directions:
+ |wn,ne,e,se,s,sw,w,nw|n (north, northeast etc)
+ |wu,d|n (up and down)
+ |wi,o|n (in and out)
+ The full names (north, in, southwest, etc) will always be put as
+ main name for the exit, using the abbreviation as an alias (so an
+ exit will always be able to be used with both "north" as well as
+ "n" for example). Opposite directions will automatically be
+ created back from the new room unless the /oneway switch is given.
+ For more flexibility and power in creating rooms, use dig.
+ """
+
+ key="tunnel"
+ aliases=["tun"]
+ switch_options=("oneway","tel")
+ locks="cmd: perm(tunnel) or perm(Builder)"
+ help_category="Building"
+
+ # store the direction, full name and its opposite
+ directions={
+ "n":("north","s"),
+ "ne":("northeast","sw"),
+ "e":("east","w"),
+ "se":("southeast","nw"),
+ "s":("south","n"),
+ "sw":("southwest","ne"),
+ "w":("west","e"),
+ "nw":("northwest","se"),
+ "u":("up","d"),
+ "d":("down","u"),
+ "i":("in","o"),
+ "o":("out","i"),
+ }
+
+
[docs]deffunc(self):
+ """Implements the tunnel command"""
+
+ ifnotself.argsornotself.lhs:
+ string=(
+ "Usage: tunnel[/switch] <direction>[:typeclass] [= <roomname>"
+ "[;alias;alias;...][:typeclass]]"
+ )
+ self.caller.msg(string)
+ return
+
+ # If we get a typeclass, we need to get just the exitname
+ exitshort=self.lhs.split(":")[0]
+
+ ifexitshortnotinself.directions:
+ string="tunnel can only understand the following directions: %s."%",".join(
+ sorted(self.directions.keys())
+ )
+ string+="\n(use dig for more freedom)"
+ self.caller.msg(string)
+ return
+
+ # retrieve all input and parse it
+ exitname,backshort=self.directions[exitshort]
+ backname=self.directions[backshort][0]
+
+ # if we recieved a typeclass for the exit, add it to the alias(short name)
+ if":"inself.lhs:
+ # limit to only the first : character
+ exit_typeclass=":"+self.lhs.split(":",1)[-1]
+ # exitshort and backshort are the last part of the exit strings,
+ # so we add our typeclass argument after
+ exitshort+=exit_typeclass
+ backshort+=exit_typeclass
+
+ roomname="Some place"
+ ifself.rhs:
+ roomname=self.rhs# this may include aliases; that's fine.
+
+ telswitch=""
+ if"tel"inself.switches:
+ telswitch="/teleport"
+ backstring=""
+ if"oneway"notinself.switches:
+ backstring=", %s;%s"%(backname,backshort)
+
+ # build the string we will use to call dig
+ digstring="dig%s%s = %s;%s%s"%(telswitch,roomname,exitname,exitshort,backstring)
+ self.execute_cmd(digstring)
+
+
+
[docs]classCmdLink(COMMAND_DEFAULT_CLASS):
+ """
+ link existing rooms together with exits
+
+ Usage:
+ link[/switches] <object> = <target>
+ link[/switches] <object> =
+ link[/switches] <object>
+
+ Switch:
+ twoway - connect two exits. For this to work, BOTH <object>
+ and <target> must be exit objects.
+
+ If <object> is an exit, set its destination to <target>. Two-way operation
+ instead sets the destination to the *locations* of the respective given
+ arguments.
+ The second form (a lone =) sets the destination to None (same as
+ the unlink command) and the third form (without =) just shows the
+ currently set destination.
+ """
+
+ key="link"
+ locks="cmd:perm(link) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Perform the link"""
+ caller=self.caller
+
+ ifnotself.args:
+ caller.msg("Usage: link[/twoway] <object> = <target>")
+ return
+
+ object_name=self.lhs
+
+ # try to search locally first
+ results=caller.search(object_name,quiet=True)
+ iflen(results)>1:# local results was a multimatch. Inform them to be more specific
+ _AT_SEARCH_RESULT=variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".",1))
+ return_AT_SEARCH_RESULT(results,caller,query=object_name)
+ eliflen(results)==1:# A unique local match
+ obj=results[0]
+ else:# No matches. Search globally
+ obj=caller.search(object_name,global_search=True)
+ ifnotobj:
+ return
+
+ ifself.rhs:
+ # this means a target name was given
+ target=caller.search(self.rhs,global_search=True)
+ ifnottarget:
+ return
+
+ iftarget==obj:
+ self.caller.msg("Cannot link an object to itself.")
+ return
+
+ string=""
+ note="Note: %s(%s) did not have a destination set before. Make sure you linked the right thing."
+ ifnotobj.destination:
+ string=note%(obj.name,obj.dbref)
+ if"twoway"inself.switches:
+ ifnot(target.locationandobj.location):
+ string="To create a two-way link, %s and %s must both have a location"%(
+ obj,
+ target,
+ )
+ string+=" (i.e. they cannot be rooms, but should be exits)."
+ self.caller.msg(string)
+ return
+ ifnottarget.destination:
+ string+=note%(target.name,target.dbref)
+ obj.destination=target.location
+ target.destination=obj.location
+ string+="\nLink created %s (in %s) <-> %s (in %s) (two-way)."%(
+ obj.name,
+ obj.location,
+ target.name,
+ target.location,
+ )
+ else:
+ obj.destination=target
+ string+="\nLink created %s -> %s (one way)."%(obj.name,target)
+
+ elifself.rhsisNone:
+ # this means that no = was given (otherwise rhs
+ # would have been an empty string). So we inspect
+ # the home/destination on object
+ dest=obj.destination
+ ifdest:
+ string="%s is an exit to %s."%(obj.name,dest.name)
+ else:
+ string="%s is not an exit. Its home location is %s."%(obj.name,obj.home)
+
+ else:
+ # We gave the command link 'obj = ' which means we want to
+ # clear destination.
+ ifobj.destination:
+ obj.destination=None
+ string="Former exit %s no longer links anywhere."%obj.name
+ else:
+ string="%s had no destination to unlink."%obj.name
+ # give feedback
+ caller.msg(string.strip())
+
+
+
[docs]classCmdUnLink(CmdLink):
+ """
+ remove exit-connections between rooms
+
+ Usage:
+ unlink <Object>
+
+ Unlinks an object, for example an exit, disconnecting
+ it from whatever it was connected to.
+ """
+
+ # this is just a child of CmdLink
+
+ key="unlink"
+ locks="cmd:perm(unlink) or perm(Builder)"
+ help_key="Building"
+
+
[docs]deffunc(self):
+ """
+ All we need to do here is to set the right command
+ and call func in CmdLink
+ """
+
+ caller=self.caller
+
+ ifnotself.args:
+ caller.msg("Usage: unlink <object>")
+ return
+
+ # This mimics 'link <obj> = ' which is the same as unlink
+ self.rhs=""
+
+ # call the link functionality
+ super().func()
+
+
+
[docs]classCmdSetHome(CmdLink):
+ """
+ set an object's home location
+
+ Usage:
+ sethome <obj> [= <home_location>]
+ sethom <obj>
+
+ The "home" location is a "safety" location for objects; they
+ will be moved there if their current location ceases to exist. All
+ objects should always have a home location for this reason.
+ It is also a convenient target of the "home" command.
+
+ If no location is given, just view the object's home location.
+ """
+
+ key="sethome"
+ locks="cmd:perm(sethome) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """implement the command"""
+ ifnotself.args:
+ string="Usage: sethome <obj> [= <home_location>]"
+ self.caller.msg(string)
+ return
+
+ obj=self.caller.search(self.lhs,global_search=True)
+ ifnotobj:
+ return
+ ifnotself.rhs:
+ # just view
+ home=obj.home
+ ifnothome:
+ string="This object has no home location set!"
+ else:
+ string="%s's current home is %s(%s)."%(obj,home,home.dbref)
+ else:
+ # set a home location
+ new_home=self.caller.search(self.rhs,global_search=True)
+ ifnotnew_home:
+ return
+ old_home=obj.home
+ obj.home=new_home
+ ifold_home:
+ string="Home location of %s was changed from %s(%s) to %s(%s)."%(
+ obj,
+ old_home,
+ old_home.dbref,
+ new_home,
+ new_home.dbref,
+ )
+ else:
+ string="Home location of %s was set to %s(%s)."%(obj,new_home,new_home.dbref)
+ self.caller.msg(string)
+
+
+
[docs]classCmdListCmdSets(COMMAND_DEFAULT_CLASS):
+ """
+ list command sets defined on an object
+
+ Usage:
+ cmdsets <obj>
+
+ This displays all cmdsets assigned
+ to a user. Defaults to yourself.
+ """
+
+ key="cmdsets"
+ aliases="listcmsets"
+ locks="cmd:perm(listcmdsets) or perm(Builder)"
+ help_category="Building"
+
+
[docs]classCmdName(ObjManipCommand):
+ """
+ change the name and/or aliases of an object
+
+ Usage:
+ name <obj> = <newname>;alias1;alias2
+
+ Rename an object to something new. Use *obj to
+ rename an account.
+
+ """
+
+ key="name"
+ aliases=["rename"]
+ locks="cmd:perm(rename) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """change the name"""
+
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("Usage: name <obj> = <newname>[;alias;alias;...]")
+ return
+
+ obj=None
+ ifself.lhs_objs:
+ objname=self.lhs_objs[0]["name"]
+ ifobjname.startswith("*"):
+ # account mode
+ obj=caller.account.search(objname.lstrip("*"))
+ ifobj:
+ ifself.rhs_objs[0]["aliases"]:
+ caller.msg("Accounts can't have aliases.")
+ return
+ newname=self.rhs
+ ifnotnewname:
+ caller.msg("No name defined!")
+ return
+ ifnot(obj.access(caller,"control")orobj.access(caller,"edit")):
+ caller.msg("You don't have right to edit this account %s."%obj)
+ return
+ obj.username=newname
+ obj.save()
+ caller.msg("Account's name changed to '%s'."%newname)
+ return
+ # object search, also with *
+ obj=caller.search(objname)
+ ifnotobj:
+ return
+ ifself.rhs_objs:
+ newname=self.rhs_objs[0]["name"]
+ aliases=self.rhs_objs[0]["aliases"]
+ else:
+ newname=self.rhs
+ aliases=None
+ ifnotnewnameandnotaliases:
+ caller.msg("No names or aliases defined!")
+ return
+ ifnot(obj.access(caller,"control")orobj.access(caller,"edit")):
+ caller.msg("You don't have the right to edit %s."%obj)
+ return
+ # change the name and set aliases:
+ ifnewname:
+ obj.name=newname
+ astring=""
+ ifaliases:
+ [obj.aliases.add(alias)foraliasinaliases]
+ astring=" (%s)"%(", ".join(aliases))
+ # fix for exits - we need their exit-command to change name too
+ ifobj.destination:
+ obj.flush_from_cache(force=True)
+ caller.msg("Object's name changed to '%s'%s."%(newname,astring))
+
+
+
[docs]classCmdOpen(ObjManipCommand):
+ """
+ open a new exit from the current room
+
+ Usage:
+ open <new exit>[;alias;alias..][:typeclass] [,<return exit>[;alias;..][:typeclass]]] = <destination>
+
+ Handles the creation of exits. If a destination is given, the exit
+ will point there. The <return exit> argument sets up an exit at the
+ destination leading back to the current room. Destination name
+ can be given both as a #dbref and a name, if that name is globally
+ unique.
+
+ """
+
+ key="open"
+ locks="cmd:perm(open) or perm(Builder)"
+ help_category="Building"
+
+ new_obj_lockstring="control:id({id}) or perm(Admin);delete:id({id}) or perm(Admin)"
+ # a custom member method to chug out exits and do checks
+
[docs]defcreate_exit(self,exit_name,location,destination,exit_aliases=None,typeclass=None):
+ """
+ Helper function to avoid code duplication.
+ At this point we know destination is a valid location
+
+ """
+ caller=self.caller
+ string=""
+ # check if this exit object already exists at the location.
+ # we need to ignore errors (so no automatic feedback)since we
+ # have to know the result of the search to decide what to do.
+ exit_obj=caller.search(exit_name,location=location,quiet=True,exact=True)
+ iflen(exit_obj)>1:
+ # give error message and return
+ caller.search(exit_name,location=location,exact=True)
+ returnNone
+ ifexit_obj:
+ exit_obj=exit_obj[0]
+ ifnotexit_obj.destination:
+ # we are trying to link a non-exit
+ string="'%s' already exists and is not an exit!\nIf you want to convert it "
+ string+=(
+ "to an exit, you must assign an object to the 'destination' property first."
+ )
+ caller.msg(string%exit_name)
+ returnNone
+ # we are re-linking an old exit.
+ old_destination=exit_obj.destination
+ ifold_destination:
+ string="Exit %s already exists."%exit_name
+ ifold_destination.id!=destination.id:
+ # reroute the old exit.
+ exit_obj.destination=destination
+ ifexit_aliases:
+ [exit_obj.aliases.add(alias)foraliasinexit_aliases]
+ string+=" Rerouted its old destination '%s' to '%s' and changed aliases."%(
+ old_destination.name,
+ destination.name,
+ )
+ else:
+ string+=" It already points to the correct place."
+
+ else:
+ # exit does not exist before. Create a new one.
+ lockstring=self.new_obj_lockstring.format(id=caller.id)
+ ifnottypeclass:
+ typeclass=settings.BASE_EXIT_TYPECLASS
+ exit_obj=create.create_object(
+ typeclass,
+ key=exit_name,
+ location=location,
+ aliases=exit_aliases,
+ locks=lockstring,
+ report_to=caller,
+ )
+ ifexit_obj:
+ # storing a destination is what makes it an exit!
+ exit_obj.destination=destination
+ string=(
+ ""
+ ifnotexit_aliases
+ else" (aliases: %s)"%(", ".join([str(e)foreinexit_aliases]))
+ )
+ string="Created new Exit '%s' from %s to %s%s."%(
+ exit_name,
+ location.name,
+ destination.name,
+ string,
+ )
+ else:
+ string="Error: Exit '%s' not created."%exit_name
+ # emit results
+ caller.msg(string)
+ returnexit_obj
+
+
[docs]deffunc(self):
+ """
+ This is where the processing starts.
+ Uses the ObjManipCommand.parser() for pre-processing
+ as well as the self.create_exit() method.
+ """
+ caller=self.caller
+
+ ifnotself.argsornotself.rhs:
+ string="Usage: open <new exit>[;alias...][:typeclass][,<return exit>[;alias..][:typeclass]]] "
+ string+="= <destination>"
+ caller.msg(string)
+ return
+
+ # We must have a location to open an exit
+ location=caller.location
+ ifnotlocation:
+ caller.msg("You cannot create an exit from a None-location.")
+ return
+
+ # obtain needed info from cmdline
+
+ exit_name=self.lhs_objs[0]["name"]
+ exit_aliases=self.lhs_objs[0]["aliases"]
+ exit_typeclass=self.lhs_objs[0]["option"]
+ dest_name=self.rhs
+
+ # first, check so the destination exists.
+ destination=caller.search(dest_name,global_search=True)
+ ifnotdestination:
+ return
+
+ # Create exit
+ ok=self.create_exit(exit_name,location,destination,exit_aliases,exit_typeclass)
+ ifnotok:
+ # an error; the exit was not created, so we quit.
+ return
+ # Create back exit, if any
+ iflen(self.lhs_objs)>1:
+ back_exit_name=self.lhs_objs[1]["name"]
+ back_exit_aliases=self.lhs_objs[1]["aliases"]
+ back_exit_typeclass=self.lhs_objs[1]["option"]
+ self.create_exit(
+ back_exit_name,destination,location,back_exit_aliases,back_exit_typeclass
+ )
+
+
+def_convert_from_string(cmd,strobj):
+ """
+ Converts a single object in *string form* to its equivalent python
+ type.
+
+ Python earlier than 2.6:
+ Handles floats, ints, and limited nested lists and dicts
+ (can't handle lists in a dict, for example, this is mainly due to
+ the complexity of parsing this rather than any technical difficulty -
+ if there is a need for set-ing such complex structures on the
+ command line we might consider adding it).
+ Python 2.6 and later:
+ Supports all Python structures through literal_eval as long as they
+ are valid Python syntax. If they are not (such as [test, test2], ie
+ without the quotes around the strings), the entire structure will
+ be converted to a string and a warning will be given.
+
+ We need to convert like this since all data being sent over the
+ telnet connection by the Account is text - but we will want to
+ store it as the "real" python type so we can do convenient
+ comparisons later (e.g. obj.db.value = 2, if value is stored as a
+ string this will always fail).
+ """
+
+ # Use literal_eval to parse python structure exactly.
+ try:
+ return_LITERAL_EVAL(strobj)
+ except(SyntaxError,ValueError):
+ # treat as string
+ strobj=utils.to_str(strobj)
+ string=(
+ '|RNote: name "|r%s|R" was converted to a string. '
+ "Make sure this is acceptable."%strobj
+ )
+ cmd.caller.msg(string)
+ returnstrobj
+ exceptExceptionaserr:
+ string="|RUnknown error in evaluating Attribute: {}".format(err)
+ returnstring
+
+
+
[docs]classCmdSetAttribute(ObjManipCommand):
+ """
+ set attribute on an object or account
+
+ Usage:
+ set <obj>/<attr> = <value>
+ set <obj>/<attr> =
+ set <obj>/<attr>
+ set *<account>/<attr> = <value>
+
+ Switch:
+ edit: Open the line editor (string values only)
+ script: If we're trying to set an attribute on a script
+ channel: If we're trying to set an attribute on a channel
+ account: If we're trying to set an attribute on an account
+ room: Setting an attribute on a room (global search)
+ exit: Setting an attribute on an exit (global search)
+ char: Setting an attribute on a character (global search)
+ character: Alias for char, as above.
+
+ Sets attributes on objects. The second example form above clears a
+ previously set attribute while the third form inspects the current value of
+ the attribute (if any). The last one (with the star) is a shortcut for
+ operating on a player Account rather than an Object.
+
+ The most common data to save with this command are strings and
+ numbers. You can however also set Python primitives such as lists,
+ dictionaries and tuples on objects (this might be important for
+ the functionality of certain custom objects). This is indicated
+ by you starting your value with one of |c'|n, |c"|n, |c(|n, |c[|n
+ or |c{ |n.
+
+ Once you have stored a Python primitive as noted above, you can include
+ |c[<key>]|n in <attr> to reference nested values in e.g. a list or dict.
+
+ Remember that if you use Python primitives like this, you must
+ write proper Python syntax too - notably you must include quotes
+ around your strings or you will get an error.
+
+ """
+
+ key="set"
+ locks="cmd:perm(set) or perm(Builder)"
+ help_category="Building"
+ nested_re=re.compile(r"\[.*?\]")
+ not_found=object()
+
+
[docs]defcheck_obj(self,obj):
+ """
+ This may be overridden by subclasses in case restrictions need to be
+ placed on whether certain objects can have attributes set by certain
+ accounts.
+
+ This function is expected to display its own error message.
+
+ Returning False will abort the command.
+ """
+ returnTrue
+
+
[docs]defcheck_attr(self,obj,attr_name):
+ """
+ This may be overridden by subclasses in case restrictions need to be
+ placed on what attributes can be set by who beyond the normal lock.
+
+ This functions is expected to display its own error message. It is
+ run once for every attribute that is checked, blocking only those
+ attributes which are not permitted and letting the others through.
+ """
+ returnattr_name
+
+
[docs]defsplit_nested_attr(self,attr):
+ """
+ Yields tuples of (possible attr name, nested keys on that attr).
+ For performance, this is biased to the deepest match, but allows compatability
+ with older attrs that might have been named with `[]`'s.
+
+ > list(split_nested_attr("nested['asdf'][0]"))
+ [
+ ('nested', ['asdf', 0]),
+ ("nested['asdf']", [0]),
+ ("nested['asdf'][0]", []),
+ ]
+ """
+ quotes="\"'"
+
+ defclean_key(val):
+ val=val.strip("[]")
+ ifval[0]inquotes:
+ returnval.strip(quotes)
+ ifval[0]==LIST_APPEND_CHAR:
+ # List insert/append syntax
+ returnval
+ try:
+ returnint(val)
+ exceptValueError:
+ returnval
+
+ parts=self.nested_re.findall(attr)
+
+ base_attr=""
+ ifparts:
+ base_attr=attr[:attr.find(parts[0])]
+ forindex,partinenumerate(parts):
+ yield(base_attr,[clean_key(p)forpinparts[index:]])
+ base_attr+=part
+ yield(attr,[])
[docs]defview_attr(self,obj,attr):
+ """
+ Look up the value of an attribute and return a string displaying it.
+ """
+ nested=False
+ forkey,nested_keysinself.split_nested_attr(attr):
+ nested=True
+ ifobj.attributes.has(key):
+ val=obj.attributes.get(key)
+ val=self.do_nested_lookup(val,*nested_keys)
+ ifvalisnotself.not_found:
+ return"\nAttribute %s/%s = %s"%(obj.name,attr,val)
+ error="\n%s has no attribute '%s'."%(obj.name,attr)
+ ifnested:
+ error+=" (Nested lookups attempted)"
+ returnerror
+
+
[docs]defrm_attr(self,obj,attr):
+ """
+ Remove an attribute from the object, or a nested data structure, and report back.
+ """
+ nested=False
+ forkey,nested_keysinself.split_nested_attr(attr):
+ nested=True
+ ifobj.attributes.has(key):
+ ifnested_keys:
+ del_key=nested_keys[-1]
+ val=obj.attributes.get(key)
+ deep=self.do_nested_lookup(val,*nested_keys[:-1])
+ ifdeepisnotself.not_found:
+ try:
+ deldeep[del_key]
+ except(IndexError,KeyError,TypeError):
+ continue
+ return"\nDeleted attribute '%s' (= nested) from %s."%(attr,obj.name)
+ else:
+ exists=obj.attributes.has(key)
+ obj.attributes.remove(attr)
+ return"\nDeleted attribute '%s' (= %s) from %s."%(attr,exists,obj.name)
+ error="\n%s has no attribute '%s'."%(obj.name,attr)
+ ifnested:
+ error+=" (Nested lookups attempted)"
+ returnerror
+
+
[docs]defset_attr(self,obj,attr,value):
+ done=False
+ forkey,nested_keysinself.split_nested_attr(attr):
+ ifobj.attributes.has(key)andnested_keys:
+ acc_key=nested_keys[-1]
+ lookup_value=obj.attributes.get(key)
+ deep=self.do_nested_lookup(lookup_value,*nested_keys[:-1])
+ ifdeepisnotself.not_found:
+ # To support appending and inserting to lists
+ # a key that starts with LIST_APPEND_CHAR will insert a new item at that
+ # location, and move the other elements down.
+ # Using LIST_APPEND_CHAR alone will append to the list
+ ifisinstance(acc_key,str)andacc_key[0]==LIST_APPEND_CHAR:
+ try:
+ iflen(acc_key)>1:
+ where=int(acc_key[1:])
+ deep.insert(where,value)
+ else:
+ deep.append(value)
+ except(ValueError,AttributeError):
+ pass
+ else:
+ value=lookup_value
+ attr=key
+ done=True
+ break
+
+ # List magic failed, just use like a key/index
+ try:
+ deep[acc_key]=value
+ exceptTypeErroraserr:
+ # Tuples can't be modified
+ return"\n%s - %s"%(err,deep)
+
+ value=lookup_value
+ attr=key
+ done=True
+ break
+
+ verb="Modified"ifobj.attributes.has(attr)else"Created"
+ try:
+ ifnotdone:
+ obj.attributes.add(attr,value)
+ return"\n%s attribute %s/%s = %s"%(verb,obj.name,attr,repr(value))
+ exceptSyntaxError:
+ # this means literal_eval tried to parse a faulty string
+ return(
+ "\n|RCritical Python syntax error in your value. Only "
+ "primitive Python structures are allowed.\nYou also "
+ "need to use correct Python syntax. Remember especially "
+ "to put quotes around all strings inside lists and "
+ "dicts.|n"
+ )
+
+
[docs]defedit_handler(self,obj,attr):
+ """Activate the line editor"""
+
+ defload(caller):
+ """Called for the editor to load the buffer"""
+ old_value=obj.attributes.get(attr)
+ ifold_valueisnotNoneandnotisinstance(old_value,str):
+ typ=type(old_value).__name__
+ self.caller.msg(
+ "|RWARNING! Saving this buffer will overwrite the "
+ "current attribute (of type %s) with a string!|n"%typ
+ )
+ returnstr(old_value)
+ returnold_value
+
+ defsave(caller,buf):
+ """Called when editor saves its buffer."""
+ obj.attributes.add(attr,buf)
+ caller.msg("Saved Attribute %s."%attr)
+
+ # start the editor
+ EvEditor(self.caller,load,save,key="%s/%s"%(obj,attr))
+
+
[docs]defsearch_for_obj(self,objname):
+ """
+ Searches for an object matching objname. The object may be of different typeclasses.
+ Args:
+ objname: Name of the object we're looking for
+
+ Returns:
+ A typeclassed object, or None if nothing is found.
+ """
+ fromevennia.utils.utilsimportvariable_from_module
+
+ _AT_SEARCH_RESULT=variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".",1))
+ caller=self.caller
+ ifobjname.startswith("*")or"account"inself.switches:
+ found_obj=caller.search_account(objname.lstrip("*"))
+ elif"script"inself.switches:
+ found_obj=_AT_SEARCH_RESULT(search.search_script(objname),caller)
+ elif"channel"inself.switches:
+ found_obj=_AT_SEARCH_RESULT(search.search_channel(objname),caller)
+ else:
+ global_search=True
+ if"char"inself.switchesor"character"inself.switches:
+ typeclass=settings.BASE_CHARACTER_TYPECLASS
+ elif"room"inself.switches:
+ typeclass=settings.BASE_ROOM_TYPECLASS
+ elif"exit"inself.switches:
+ typeclass=settings.BASE_EXIT_TYPECLASS
+ else:
+ global_search=False
+ typeclass=None
+ found_obj=caller.search(objname,global_search=global_search,typeclass=typeclass)
+ returnfound_obj
+
+
[docs]deffunc(self):
+ """Implement the set attribute - a limited form of py."""
+
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("Usage: set obj/attr = value. Use empty value to clear.")
+ return
+
+ # get values prepared by the parser
+ value=self.rhs
+ objname=self.lhs_objattr[0]["name"]
+ attrs=self.lhs_objattr[0]["attrs"]
+
+ obj=self.search_for_obj(objname)
+ ifnotobj:
+ return
+
+ ifnotself.check_obj(obj):
+ return
+
+ result=[]
+ if"edit"inself.switches:
+ # edit in the line editor
+ ifnot(obj.access(self.caller,"control")orobj.access(self.caller,"edit")):
+ caller.msg("You don't have permission to edit %s."%obj.key)
+ return
+
+ iflen(attrs)>1:
+ caller.msg("The Line editor can only be applied ""to one attribute at a time.")
+ return
+ ifnotattrs:
+ caller.msg("Use `set/edit <objname>/<attr>` to define the Attribute to edit.\nTo "
+ "edit the current room description, use `set/edit here/desc` (or "
+ "use the `desc` command).")
+ return
+ self.edit_handler(obj,attrs[0])
+ return
+ ifnotvalue:
+ ifself.rhsisNone:
+ # no = means we inspect the attribute(s)
+ ifnotattrs:
+ attrs=[attr.keyforattrinobj.attributes.all()]
+ forattrinattrs:
+ ifnotself.check_attr(obj,attr):
+ continue
+ result.append(self.view_attr(obj,attr))
+ # we view it without parsing markup.
+ self.caller.msg("".join(result).strip(),options={"raw":True})
+ return
+ else:
+ # deleting the attribute(s)
+ ifnot(obj.access(self.caller,"control")orobj.access(self.caller,"edit")):
+ caller.msg("You don't have permission to edit %s."%obj.key)
+ return
+ forattrinattrs:
+ ifnotself.check_attr(obj,attr):
+ continue
+ result.append(self.rm_attr(obj,attr))
+ else:
+ # setting attribute(s). Make sure to convert to real Python type before saving.
+ ifnot(obj.access(self.caller,"control")orobj.access(self.caller,"edit")):
+ caller.msg("You don't have permission to edit %s."%obj.key)
+ return
+ forattrinattrs:
+ ifnotself.check_attr(obj,attr):
+ continue
+ value=_convert_from_string(self,value)
+ result.append(self.set_attr(obj,attr,value))
+ # send feedback
+ caller.msg("".join(result).strip("\n"))
+
+
+
[docs]classCmdTypeclass(COMMAND_DEFAULT_CLASS):
+ """
+ set or change an object's typeclass
+
+ Usage:
+ typeclass[/switch] <object> [= typeclass.path]
+ typeclass/prototype <object> = prototype_key
+
+ typeclass/list/show [typeclass.path]
+ swap - this is a shorthand for using /force/reset flags.
+ update - this is a shorthand for using the /force/reload flag.
+
+ Switch:
+ show, examine - display the current typeclass of object (default) or, if
+ given a typeclass path, show the docstring of that typeclass.
+ update - *only* re-run at_object_creation on this object
+ meaning locks or other properties set later may remain.
+ reset - clean out *all* the attributes and properties on the
+ object - basically making this a new clean object. This will
+ also reset cmdsets.
+ force - change to the typeclass also if the object
+ already has a typeclass of the same name.
+ list - show available typeclasses. Only typeclasses in modules actually
+ imported or used from somewhere in the code will show up here
+ (those typeclasses are still available if you know the path)
+ prototype - clean and overwrite the object with the specified
+ prototype key - effectively making a whole new object.
+
+ Example:
+ type button = examples.red_button.RedButton
+ type/prototype button=a red button
+
+ If the typeclass_path is not given, the current object's typeclass is
+ assumed.
+
+ View or set an object's typeclass. If setting, the creation hooks of the
+ new typeclass will be run on the object. If you have clashing properties on
+ the old class, use /reset. By default you are protected from changing to a
+ typeclass of the same name as the one you already have - use /force to
+ override this protection.
+
+ The given typeclass must be identified by its location using python
+ dot-notation pointing to the correct module and class. If no typeclass is
+ given (or a wrong typeclass is given). Errors in the path or new typeclass
+ will lead to the old typeclass being kept. The location of the typeclass
+ module is searched from the default typeclass directory, as defined in the
+ server settings.
+
+ """
+
+ key="typeclass"
+ aliases=["type","parent","swap","update"]
+ switch_options=("show","examine","update","reset","force","list","prototype")
+ locks="cmd:perm(typeclass) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Implements command"""
+
+ caller=self.caller
+
+ if"list"inself.switches:
+ tclasses=get_all_typeclasses()
+ contribs=[keyforkeyinsorted(tclasses)ifkey.startswith("evennia.contrib")]or[
+ "<None loaded>"
+ ]
+ core=[
+ keyforkeyinsorted(tclasses)ifkey.startswith("evennia")andkeynotincontribs
+ ]or["<None loaded>"]
+ game=[keyforkeyinsorted(tclasses)ifnotkey.startswith("evennia")]or[
+ "<None loaded>"
+ ]
+ string=(
+ "|wCore typeclasses|n\n"
+ " {core}\n"
+ "|wLoaded Contrib typeclasses|n\n"
+ " {contrib}\n"
+ "|wGame-dir typeclasses|n\n"
+ " {game}"
+ ).format(
+ core="\n ".join(core),contrib="\n ".join(contribs),game="\n ".join(game)
+ )
+ EvMore(caller,string,exit_on_lastpage=True)
+ return
+
+ ifnotself.args:
+ caller.msg("Usage: %s <object> [= typeclass]"%self.cmdstring)
+ return
+
+ if"show"inself.switchesor"examine"inself.switches:
+ oquery=self.lhs
+ obj=caller.search(oquery,quiet=True)
+ ifnotobj:
+ # no object found to examine, see if it's a typeclass-path instead
+ tclasses=get_all_typeclasses()
+ matches=[
+ (key,tclass)forkey,tclassintclasses.items()ifkey.endswith(oquery)
+ ]
+ nmatches=len(matches)
+ ifnmatches>1:
+ caller.msg(
+ "Multiple typeclasses found matching {}:\n{}".format(
+ oquery,"\n ".join(tup[0]fortupinmatches)
+ )
+ )
+ elifnotmatches:
+ caller.msg("No object or typeclass path found to match '{}'".format(oquery))
+ else:
+ # one match found
+ caller.msg(
+ "Docstring for typeclass '{}':\n{}".format(oquery,matches[0][1].__doc__)
+ )
+ else:
+ # do the search again to get the error handling in case of multi-match
+ obj=caller.search(oquery)
+ ifnotobj:
+ return
+ caller.msg(
+ "{}'s current typeclass is '{}.{}'".format(
+ obj.name,obj.__class__.__module__,obj.__class__.__name__
+ )
+ )
+ return
+
+ # get object to swap on
+ obj=caller.search(self.lhs)
+ ifnotobj:
+ return
+
+ ifnothasattr(obj,"__dbclass__"):
+ string="%s is not a typed object."%obj.name
+ caller.msg(string)
+ return
+
+ new_typeclass=self.rhsorobj.path
+
+ prototype=None
+ if"prototype"inself.switches:
+ key=self.rhs
+ prototype=protlib.search_prototype(key=key)
+ iflen(prototype)>1:
+ caller.msg(
+ "More than one match for {}:\n{}".format(
+ key,"\n".join(proto.get("prototype_key","")forprotoinprototype)
+ )
+ )
+ return
+ elifprototype:
+ # one match
+ prototype=prototype[0]
+ else:
+ # no match
+ caller.msg("No prototype '{}' was found.".format(key))
+ return
+ new_typeclass=prototype["typeclass"]
+ self.switches.append("force")
+
+ if"show"inself.switchesor"examine"inself.switches:
+ string="%s's current typeclass is %s."%(obj.name,obj.__class__)
+ caller.msg(string)
+ return
+
+ ifself.cmdstring=="swap":
+ self.switches.append("force")
+ self.switches.append("reset")
+ elifself.cmdstring=="update":
+ self.switches.append("force")
+ self.switches.append("update")
+
+ ifnot(obj.access(caller,"control")orobj.access(caller,"edit")):
+ caller.msg("You are not allowed to do that.")
+ return
+
+ ifnothasattr(obj,"swap_typeclass"):
+ caller.msg("This object cannot have a type at all!")
+ return
+
+ is_same=obj.is_typeclass(new_typeclass,exact=True)
+ ifis_sameand"force"notinself.switches:
+ string="%s already has the typeclass '%s'. Use /force to override."%(
+ obj.name,
+ new_typeclass,
+ )
+ else:
+ update="update"inself.switches
+ reset="reset"inself.switches
+ hooks="at_object_creation"ifupdateandnotresetelse"all"
+ old_typeclass_path=obj.typeclass_path
+
+ # special prompt for the user in cases where we want
+ # to confirm changes.
+ if"prototype"inself.switches:
+ diff,_=spawner.prototype_diff_from_object(prototype,obj)
+ txt=spawner.format_diff(diff)
+ prompt=(
+ "Applying prototype '%s' over '%s' will cause the follow changes:\n%s\n"
+ %(prototype["key"],obj.name,txt)
+ )
+ ifnotreset:
+ prompt+="\n|yWARNING:|n Use the /reset switch to apply the prototype over a blank state."
+ prompt+="\nAre you sure you want to apply these changes [yes]/no?"
+ answer=yield(prompt)
+ ifanswerandanswerin("no","n"):
+ caller.msg("Canceled: No changes were applied.")
+ return
+
+ # we let this raise exception if needed
+ obj.swap_typeclass(
+ new_typeclass,clean_attributes=reset,clean_cmdsets=reset,run_start_hooks=hooks
+ )
+
+ if"prototype"inself.switches:
+ modified=spawner.batch_update_objects_with_prototype(prototype,objects=[obj])
+ prototype_success=modified>0
+ ifnotprototype_success:
+ caller.msg("Prototype %s failed to apply."%prototype["key"])
+
+ ifis_same:
+ string="%s updated its existing typeclass (%s).\n"%(obj.name,obj.path)
+ else:
+ string="%s changed typeclass from %s to %s.\n"%(
+ obj.name,
+ old_typeclass_path,
+ obj.typeclass_path,
+ )
+ ifupdate:
+ string+="Only the at_object_creation hook was run (update mode)."
+ else:
+ string+="All object creation hooks were run."
+ ifreset:
+ string+=" All old attributes where deleted before the swap."
+ else:
+ string+=" Attributes set before swap were not removed."
+ if"prototype"inself.switchesandprototype_success:
+ string+=(
+ " Prototype '%s' was successfully applied over the object type."
+ %prototype["key"]
+ )
+
+ caller.msg(string)
+
+
+
[docs]classCmdWipe(ObjManipCommand):
+ """
+ clear all attributes from an object
+
+ Usage:
+ wipe <object>[/<attr>[/<attr>...]]
+
+ Example:
+ wipe box
+ wipe box/colour
+
+ Wipes all of an object's attributes, or optionally only those
+ matching the given attribute-wildcard search string.
+ """
+
+ key="wipe"
+ locks="cmd:perm(wipe) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """
+ inp is the dict produced in ObjManipCommand.parse()
+ """
+
+ caller=self.caller
+
+ ifnotself.args:
+ caller.msg("Usage: wipe <object>[/<attr>/<attr>...]")
+ return
+
+ # get the attributes set by our custom parser
+ objname=self.lhs_objattr[0]["name"]
+ attrs=self.lhs_objattr[0]["attrs"]
+
+ obj=caller.search(objname)
+ ifnotobj:
+ return
+ ifnot(obj.access(caller,"control")orobj.access(caller,"edit")):
+ caller.msg("You are not allowed to do that.")
+ return
+ ifnotattrs:
+ # wipe everything
+ obj.attributes.clear()
+ string="Wiped all attributes on %s."%obj.name
+ else:
+ forattrnameinattrs:
+ obj.attributes.remove(attrname)
+ string="Wiped attributes %s on %s."
+ string=string%(",".join(attrs),obj.name)
+ caller.msg(string)
+
+
+
[docs]classCmdLock(ObjManipCommand):
+ """
+ assign a lock definition to an object
+
+ Usage:
+ lock <object or *account>[ = <lockstring>]
+ or
+ lock[/switch] <object or *account>/<access_type>
+
+ Switch:
+ del - delete given access type
+ view - view lock associated with given access type (default)
+
+ If no lockstring is given, shows all locks on
+ object.
+
+ Lockstring is of the form
+ access_type:[NOT] func1(args)[ AND|OR][ NOT] func2(args) ...]
+ Where func1, func2 ... valid lockfuncs with or without arguments.
+ Separator expressions need not be capitalized.
+
+ For example:
+ 'get: id(25) or perm(Admin)'
+ The 'get' lock access_type is checked e.g. by the 'get' command.
+ An object locked with this example lock will only be possible to pick up
+ by Admins or by an object with id=25.
+
+ You can add several access_types after one another by separating
+ them by ';', i.e:
+ 'get:id(25); delete:perm(Builder)'
+ """
+
+ key="lock"
+ aliases=["locks"]
+ locks="cmd: perm(locks) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Sets up the command"""
+
+ caller=self.caller
+ ifnotself.args:
+ string=(
+ "Usage: lock <object>[ = <lockstring>] or lock[/switch] ""<object>/<access_type>"
+ )
+ caller.msg(string)
+ return
+
+ if"/"inself.lhs:
+ # call of the form lock obj/access_type
+ objname,access_type=[p.strip()forpinself.lhs.split("/",1)]
+ obj=None
+ ifobjname.startswith("*"):
+ obj=caller.search_account(objname.lstrip("*"))
+ ifnotobj:
+ obj=caller.search(objname)
+ ifnotobj:
+ return
+ has_control_access=obj.access(caller,"control")
+ ifaccess_type=="control"andnothas_control_access:
+ # only allow to change 'control' access if you have 'control' access already
+ caller.msg("You need 'control' access to change this type of lock.")
+ return
+
+ ifnot(has_control_accessorobj.access(caller,"edit")):
+ caller.msg("You are not allowed to do that.")
+ return
+
+ lockdef=obj.locks.get(access_type)
+
+ iflockdef:
+ if"del"inself.switches:
+ obj.locks.delete(access_type)
+ string="deleted lock %s"%lockdef
+ else:
+ string=lockdef
+ else:
+ string="%s has no lock of access type '%s'."%(obj,access_type)
+ caller.msg(string)
+ return
+
+ ifself.rhs:
+ # we have a = separator, so we are assigning a new lock
+ ifself.switches:
+ swi=", ".join(self.switches)
+ caller.msg(
+ "Switch(es) |w%s|n can not be used with a "
+ "lock assignment. Use e.g. "
+ "|wlock/del objname/locktype|n instead."%swi
+ )
+ return
+
+ objname,lockdef=self.lhs,self.rhs
+ obj=None
+ ifobjname.startswith("*"):
+ obj=caller.search_account(objname.lstrip("*"))
+ ifnotobj:
+ obj=caller.search(objname)
+ ifnotobj:
+ return
+ ifnot(obj.access(caller,"control")orobj.access(caller,"edit")):
+ caller.msg("You are not allowed to do that.")
+ return
+ ok=False
+ lockdef=re.sub(r"\'|\"","",lockdef)
+ try:
+ ok=obj.locks.add(lockdef)
+ exceptLockExceptionase:
+ caller.msg(str(e))
+ if"cmd"inlockdef.lower()andinherits_from(
+ obj,"evennia.objects.objects.DefaultExit"
+ ):
+ # special fix to update Exits since "cmd"-type locks won't
+ # update on them unless their cmdsets are rebuilt.
+ obj.at_init()
+ ifok:
+ caller.msg("Added lock '%s' to %s."%(lockdef,obj))
+ return
+
+ # if we get here, we are just viewing all locks on obj
+ obj=None
+ ifself.lhs.startswith("*"):
+ obj=caller.search_account(self.lhs.lstrip("*"))
+ ifnotobj:
+ obj=caller.search(self.lhs)
+ ifnotobj:
+ return
+ ifnot(obj.access(caller,"control")orobj.access(caller,"edit")):
+ caller.msg("You are not allowed to do that.")
+ return
+ caller.msg("\n".join(obj.locks.all()))
+
+
+
[docs]classCmdExamine(ObjManipCommand):
+ """
+ get detailed information about an object
+
+ Usage:
+ examine [<object>[/attrname]]
+ examine [*<account>[/attrname]]
+
+ Switch:
+ account - examine an Account (same as adding *)
+ object - examine an Object (useful when OOC)
+
+ The examine command shows detailed game info about an
+ object and optionally a specific attribute on it.
+ If object is not specified, the current location is examined.
+
+ Append a * before the search string to examine an account.
+
+ """
+
+ key="examine"
+ aliases=["ex","exam"]
+ locks="cmd:perm(examine) or perm(Builder)"
+ help_category="Building"
+ arg_regex=r"(/\w+?(\s|$))|\s|$"
+
+ account_mode=False
+ detail_color="|c"
+ header_color="|w"
+ quell_color="|r"
+ separator="-"
+
+
[docs]deflist_attribute(self,crop,attr,category,value):
+ """
+ Formats a single attribute line.
+
+ Args:
+ crop (bool): If output should be cropped if too long.
+ attr (str): Attribute key.
+ category (str): Attribute category.
+ value (any): Attribute value.
+ Returns:
+ """
+ ifattrisNone:
+ return"No such attribute was found."
+ value=utils.to_str(value)
+ ifcrop:
+ value=utils.crop(value)
+ value=inlinefunc_raw(ansi_raw(value))
+ ifcategory:
+ returnf"{attr}[{category}] = {value}"
+ else:
+ returnf"{attr} = {value}"
[docs]defformat_output(self,obj,current_cmdset):
+ """
+ Helper function that creates a nice report about an object.
+
+ Args:
+ obj (any): Object to analyze.
+ current_cmdset (CmdSet): Current cmdset for object.
+
+ Returns:
+ str: The formatted string.
+
+ """
+ hclr=self.header_color
+ dclr=self.detail_color
+ qclr=self.quell_color
+
+ output={}
+ # main key
+ output["Name/key"]=f"{dclr}{obj.name}|n ({obj.dbref})"
+ # aliases
+ ifhasattr(obj,"aliases")andobj.aliases.all():
+ output["Aliases"]=", ".join(utils.make_iter(str(obj.aliases)))
+ # typeclass
+ output["Typeclass"]=f"{obj.typename} ({obj.typeclass_path})"
+ # sessions
+ ifhasattr(obj,"sessions")andobj.sessions.all():
+ output["Session id(s)"]=", ".join(f"#{sess.sessid}"forsessinobj.sessions.all())
+ # email, if any
+ ifhasattr(obj,"email")andobj.email:
+ output["Email"]=f"{dclr}{obj.email}|n"
+ # account, for puppeted objects
+ ifhasattr(obj,"has_account")andobj.has_account:
+ output["Account"]=f"{dclr}{obj.account.name}|n ({obj.account.dbref})"
+ # account typeclass
+ output[" Account Typeclass"]=f"{obj.account.typename} ({obj.account.typeclass_path})"
+ # account permissions
+ perms=obj.account.permissions.all()
+ ifobj.account.is_superuser:
+ perms=["<Superuser>"]
+ elifnotperms:
+ perms=["<None>"]
+ perms=", ".join(perms)
+ ifobj.account.attributes.has("_quell"):
+ perms+=f" {qclr}(quelled)|n"
+ output[" Account Permissions"]=perms
+ # location
+ ifhasattr(obj,"location"):
+ loc=str(obj.location)
+ ifobj.location:
+ loc+=f" (#{obj.location.id})"
+ output["Location"]=loc
+ # home
+ ifhasattr(obj,"home"):
+ home=str(obj.home)
+ ifobj.home:
+ home+=f" (#{obj.home.id})"
+ output["Home"]=home
+ # destination, for exits
+ ifhasattr(obj,"destination")andobj.destination:
+ dest=str(obj.destination)
+ ifobj.destination:
+ dest+=f" (#{obj.destination.id})"
+ output["Destination"]=dest
+ # main permissions
+ perms=obj.permissions.all()
+ perms_string=""
+ ifperms:
+ perms_string=", ".join(perms)
+ ifobj.is_superuser:
+ perms_string+=" <Superuser>"
+ ifperms_string:
+ output["Permissions"]=perms_string
+ # locks
+ locks=str(obj.locks)
+ iflocks:
+ locks_string="\n"+utils.fill(
+ "; ".join([lockforlockinlocks.split(";")]),indent=2
+ )
+ else:
+ locks_string=" Default"
+ output["Locks"]=locks_string
+ # cmdsets
+ ifcurrent_cmdsetandnot(
+ len(obj.cmdset.all())==1andobj.cmdset.current.key=="_EMPTY_CMDSET"):
+ # all() returns a 'stack', so make a copy to sort.
+
+ def_format_options(cmdset):
+ """helper for cmdset-option display"""
+
+ def_truefalse(string,value):
+ ifvalueisNone:
+ return""
+ ifvalue:
+ returnf"{string}: T"
+ returnf"{string}: F"
+
+ options=", ".join(
+ _truefalse(opt,getattr(cmdset,opt))
+ foroptin("no_exits","no_objs","no_channels","duplicates")
+ ifgetattr(cmdset,opt)isnotNone
+ )
+ options=", "+optionsifoptionselse""
+ returnoptions
+
+ # cmdset stored on us
+ stored_cmdsets=sorted(obj.cmdset.all(),key=lambdax:x.priority,reverse=True)
+ stored=[]
+ forcmdsetinstored_cmdsets:
+ ifcmdset.key=="_EMPTY_CMDSET":
+ continue
+ options=_format_options(cmdset)
+ stored.append(
+ f"{cmdset.path} [{cmdset.key}] ({cmdset.mergetype}, prio {cmdset.priority}{options})"
+ )
+ output["Stored Cmdset(s)"]="\n "+"\n ".join(stored)
+
+ # this gets all components of the currently merged set
+ all_cmdsets=[(cmdset.key,cmdset)forcmdsetincurrent_cmdset.merged_from]
+ # we always at least try to add account- and session sets since these are ignored
+ # if we merge on the object level.
+ ifhasattr(obj,"account")andobj.account:
+ all_cmdsets.extend([(cmdset.key,cmdset)forcmdsetinobj.account.cmdset.all()])
+ ifobj.sessions.count():
+ # if there are more sessions than one on objects it's because of multisession mode 3.
+ # we only show the first session's cmdset here (it is -in principle- possible that
+ # different sessions have different cmdsets but for admins who want such madness
+ # it is better that they overload with their own CmdExamine to handle it).
+ all_cmdsets.extend(
+ [
+ (cmdset.key,cmdset)
+ forcmdsetinobj.account.sessions.all()[0].cmdset.all()
+ ]
+ )
+ else:
+ try:
+ # we have to protect this since many objects don't have sessions.
+ all_cmdsets.extend(
+ [
+ (cmdset.key,cmdset)
+ forcmdsetinobj.get_session(obj.sessions.get()).cmdset.all()
+ ]
+ )
+ except(TypeError,AttributeError):
+ # an error means we are merging an object without a session
+ pass
+ all_cmdsets=[cmdsetforcmdsetindict(all_cmdsets).values()]
+ all_cmdsets.sort(key=lambdax:x.priority,reverse=True)
+
+ # the resulting merged cmdset
+ options=_format_options(current_cmdset)
+ merged=[
+ f"<Current merged cmdset> ({current_cmdset.mergetype} prio {current_cmdset.priority}{options})"
+ ]
+
+ # the merge stack
+ forcmdsetinall_cmdsets:
+ options=_format_options(cmdset)
+ merged.append(
+ f"{cmdset.path} [{cmdset.key}] ({cmdset.mergetype} prio {cmdset.priority}{options})"
+ )
+ output["Merged Cmdset(s)"]="\n "+"\n ".join(merged)
+
+ # list the commands available to this object
+ current_commands=sorted([cmd.keyforcmdincurrent_cmdsetifcmd.access(obj,"cmd")])
+ cmdsetstr="\n"+utils.fill(", ".join(current_commands),indent=2)
+ output[f"Commands available to {obj.key} (result of Merged CmdSets)"]=str(cmdsetstr)
+
+ # scripts
+ ifhasattr(obj,"scripts")andhasattr(obj.scripts,"all")andobj.scripts.all():
+ output["Scripts"]="\n "+f"{obj.scripts}"
+ # add the attributes
+ output.update(self.format_attributes(obj))
+ # Tags
+ tags=obj.tags.all(return_key_and_category=True)
+ tags_string="\n"+utils.fill(
+ ", ".join(sorted(f"{tag}[{category}]"fortag,categoryintags)),indent=2,
+ )
+ iftags:
+ output["Tags[category]"]=tags_string
+ # Contents of object
+ exits=[]
+ pobjs=[]
+ things=[]
+ ifhasattr(obj,"contents"):
+ forcontentinobj.contents:
+ ifcontent.destination:
+ exits.append(content)
+ elifcontent.account:
+ pobjs.append(content)
+ else:
+ things.append(content)
+ ifexits:
+ output["Exits (has .destination)"]=", ".join(
+ f"{exit.name}({exit.dbref})"forexitinexits
+ )
+ ifpobjs:
+ output["Characters"]=", ".join(
+ f"{dclr}{pobj.name}|n({pobj.dbref})"forpobjinpobjs
+ )
+ ifthings:
+ output["Contents"]=", ".join(
+ f"{cont.name}({cont.dbref})"
+ forcontinobj.contents
+ ifcontnotinexitsandcontnotinpobjs
+ )
+ # format output
+ max_width=-1
+ forblockinoutput.values():
+ max_width=max(max_width,max(display_len(line)forlineinblock.split("\n")))
+ max_width=max(0,min(self.client_width(),max_width))
+
+ sep=self.separator*max_width
+ mainstr="\n".join(f"{hclr}{header}|n: {block}"for(header,block)inoutput.items())
+ returnf"{sep}\n{mainstr}\n{sep}"
+
+
[docs]deffunc(self):
+ """Process command"""
+ caller=self.caller
+
+ defget_cmdset_callback(cmdset):
+ """
+ We make use of the cmdhandeler.get_and_merge_cmdsets below. This
+ is an asynchronous function, returning a Twisted deferred.
+ So in order to properly use this we need use this callback;
+ it is called with the result of get_and_merge_cmdsets, whenever
+ that function finishes. Taking the resulting cmdset, we continue
+ to format and output the result.
+ """
+ self.msg(self.format_output(obj,cmdset).strip())
+
+ ifnotself.args:
+ # If no arguments are provided, examine the invoker's location.
+ ifhasattr(caller,"location"):
+ obj=caller.location
+ ifnotobj.access(caller,"examine"):
+ # If we don't have special info access, just look at the object instead.
+ self.msg(caller.at_look(obj))
+ return
+ obj_session=obj.sessions.get()[0]ifobj.sessions.count()elseNone
+
+ # using callback for printing result whenever function returns.
+ get_and_merge_cmdsets(
+ obj,obj_session,self.account,obj,"object",self.raw_string
+ ).addCallback(get_cmdset_callback)
+ else:
+ self.msg("You need to supply a target to examine.")
+ return
+
+ # we have given a specific target object
+ forobjdefinself.lhs_objattr:
+
+ obj=None
+ obj_name=objdef["name"]
+ obj_attrs=objdef["attrs"]
+
+ self.account_mode=(
+ utils.inherits_from(caller,"evennia.accounts.accounts.DefaultAccount")
+ or"account"inself.switches
+ orobj_name.startswith("*")
+ )
+ ifself.account_mode:
+ try:
+ obj=caller.search_account(obj_name.lstrip("*"))
+ exceptAttributeError:
+ # this means we are calling examine from an account object
+ obj=caller.search(
+ obj_name.lstrip("*"),search_object="object"inself.switches
+ )
+ else:
+ obj=caller.search(obj_name)
+ ifnotobj:
+ continue
+
+ ifnotobj.access(caller,"examine"):
+ # If we don't have special info access, just look
+ # at the object instead.
+ self.msg(caller.at_look(obj))
+ continue
+
+ ifobj_attrs:
+ forattrnameinobj_attrs:
+ # we are only interested in specific attributes
+ ret="\n".join(
+ f"{self.header_color}{header}|n:{value}"
+ forheader,valueinself.format_attributes(
+ obj,attrname,crop=False
+ ).items()
+ )
+ self.caller.msg(ret)
+ else:
+ session=None
+ ifobj.sessions.count():
+ mergemode="session"
+ session=obj.sessions.get()[0]
+ elifself.account_mode:
+ mergemode="account"
+ else:
+ mergemode="object"
+
+ account=None
+ objct=None
+ ifself.account_mode:
+ account=obj
+ else:
+ account=obj.account
+ objct=obj
+
+ # this is usually handled when a command runs, but when we examine
+ # we may have leftover inherited cmdsets directly after a move etc.
+ obj.cmdset.update()
+ # using callback to print results whenever function returns.
+ get_and_merge_cmdsets(
+ obj,session,account,objct,mergemode,self.raw_string
+ ).addCallback(get_cmdset_callback)
+
+
+
[docs]classCmdFind(COMMAND_DEFAULT_CLASS):
+ """
+ search the database for objects
+
+ Usage:
+ find[/switches] <name or dbref or *account> [= dbrefmin[-dbrefmax]]
+ locate - this is a shorthand for using the /loc switch.
+
+ Switches:
+ room - only look for rooms (location=None)
+ exit - only look for exits (destination!=None)
+ char - only look for characters (BASE_CHARACTER_TYPECLASS)
+ exact - only exact matches are returned.
+ loc - display object location if exists and match has one result
+ startswith - search for names starting with the string, rather than containing
+
+ Searches the database for an object of a particular name or exact #dbref.
+ Use *accountname to search for an account. The switches allows for
+ limiting object matches to certain game entities. Dbrefmin and dbrefmax
+ limits matches to within the given dbrefs range, or above/below if only
+ one is given.
+ """
+
+ key="find"
+ aliases="search, locate"
+ switch_options=("room","exit","char","exact","loc","startswith")
+ locks="cmd:perm(find) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Search functionality"""
+ caller=self.caller
+ switches=self.switches
+
+ ifnotself.argsor(notself.lhsandnotself.rhs):
+ caller.msg("Usage: find <string> [= low [-high]]")
+ return
+
+ if"locate"inself.cmdstring:# Use option /loc as a default for locate command alias
+ switches.append("loc")
+
+ searchstring=self.lhs
+
+ try:
+ # Try grabbing the actual min/max id values by database aggregation
+ qs=ObjectDB.objects.values("id").aggregate(low=Min("id"),high=Max("id"))
+ low,high=sorted(qs.values())
+ ifnot(lowandhigh):
+ raiseValueError(
+ f"{self.__class__.__name__}: Min and max ID not returned by aggregation; falling back to queryset slicing."
+ )
+ exceptExceptionase:
+ logger.log_trace(e)
+ # If that doesn't work for some reason (empty DB?), guess the lower
+ # bound and do a less-efficient query to find the upper.
+ low,high=1,ObjectDB.objects.all().order_by("-id").first().id
+
+ ifself.rhs:
+ try:
+ # Check that rhs is either a valid dbref or dbref range
+ bounds=tuple(
+ sorted(dbref(x,False)forxinre.split("[-\s]+",self.rhs.strip()))
+ )
+
+ # dbref() will return either a valid int or None
+ assertbounds
+ # None should not exist in the bounds list
+ assertNonenotinbounds
+
+ low=bounds[0]
+ iflen(bounds)>1:
+ high=bounds[-1]
+
+ exceptAssertionError:
+ caller.msg("Invalid dbref range provided (not a number).")
+ return
+ exceptIndexErrorase:
+ logger.log_err(
+ f"{self.__class__.__name__}: Error parsing upper and lower bounds of query."
+ )
+ logger.log_trace(e)
+
+ low=min(low,high)
+ high=max(low,high)
+
+ is_dbref=utils.dbref(searchstring)
+ is_account=searchstring.startswith("*")
+
+ restrictions=""
+ ifself.switches:
+ restrictions=", %s"%(", ".join(self.switches))
+
+ ifis_dbreforis_account:
+ ifis_dbref:
+ # a dbref search
+ result=caller.search(searchstring,global_search=True,quiet=True)
+ string="|wExact dbref match|n(#%i-#%i%s):"%(low,high,restrictions)
+ else:
+ # an account search
+ searchstring=searchstring.lstrip("*")
+ result=caller.search_account(searchstring,quiet=True)
+ string="|wMatch|n(#%i-#%i%s):"%(low,high,restrictions)
+
+ if"room"inswitches:
+ result=resultifinherits_from(result,ROOM_TYPECLASS)elseNone
+ if"exit"inswitches:
+ result=resultifinherits_from(result,EXIT_TYPECLASS)elseNone
+ if"char"inswitches:
+ result=resultifinherits_from(result,CHAR_TYPECLASS)elseNone
+
+ ifnotresult:
+ string+="\n |RNo match found.|n"
+ elifnotlow<=int(result[0].id)<=high:
+ string+="\n |RNo match found for '%s' in #dbref interval.|n"%searchstring
+ else:
+ result=result[0]
+ string+="\n|g %s - %s|n"%(result.get_display_name(caller),result.path)
+ if"loc"inself.switchesandnotis_accountandresult.location:
+ string+=" (|wlocation|n: |g{}|n)".format(
+ result.location.get_display_name(caller)
+ )
+ else:
+ # Not an account/dbref search but a wider search; build a queryset.
+ # Searches for key and aliases
+ if"exact"inswitches:
+ keyquery=Q(db_key__iexact=searchstring,id__gte=low,id__lte=high)
+ aliasquery=Q(
+ db_tags__db_key__iexact=searchstring,
+ db_tags__db_tagtype__iexact="alias",
+ id__gte=low,
+ id__lte=high,
+ )
+ elif"startswith"inswitches:
+ keyquery=Q(db_key__istartswith=searchstring,id__gte=low,id__lte=high)
+ aliasquery=Q(
+ db_tags__db_key__istartswith=searchstring,
+ db_tags__db_tagtype__iexact="alias",
+ id__gte=low,
+ id__lte=high,
+ )
+ else:
+ keyquery=Q(db_key__icontains=searchstring,id__gte=low,id__lte=high)
+ aliasquery=Q(
+ db_tags__db_key__icontains=searchstring,
+ db_tags__db_tagtype__iexact="alias",
+ id__gte=low,
+ id__lte=high,
+ )
+
+ # Keep the initial queryset handy for later reuse
+ result_qs=ObjectDB.objects.filter(keyquery|aliasquery).distinct()
+ nresults=result_qs.count()
+
+ # Use iterator to minimize memory ballooning on large result sets
+ results=result_qs.iterator()
+
+ # Check and see if type filtering was requested; skip it if not
+ ifany(xinswitchesforxin("room","exit","char")):
+ obj_ids=set()
+ forobjinresults:
+ if(
+ ("room"inswitchesandinherits_from(obj,ROOM_TYPECLASS))
+ or("exit"inswitchesandinherits_from(obj,EXIT_TYPECLASS))
+ or("char"inswitchesandinherits_from(obj,CHAR_TYPECLASS))
+ ):
+ obj_ids.add(obj.id)
+
+ # Filter previous queryset instead of requesting another
+ filtered_qs=result_qs.filter(id__in=obj_ids).distinct()
+ nresults=filtered_qs.count()
+
+ # Use iterator again to minimize memory ballooning
+ results=filtered_qs.iterator()
+
+ # still results after type filtering?
+ ifnresults:
+ ifnresults>1:
+ header=f"{nresults} Matches"
+ else:
+ header="One Match"
+
+ string=f"|w{header}|n(#{low}-#{high}{restrictions}):"
+ res=None
+ forresinresults:
+ string+=f"\n |g{res.get_display_name(caller)} - {res.path}|n"
+ if(
+ "loc"inself.switches
+ andnresults==1
+ andres
+ andgetattr(res,"location",None)
+ ):
+ string+=f" (|wlocation|n: |g{res.location.get_display_name(caller)}|n)"
+ else:
+ string=f"|wNo Matches|n(#{low}-#{high}{restrictions}):"
+ string+=f"\n |RNo matches found for '{searchstring}'|n"
+
+ # send result
+ caller.msg(string.strip())
+
+
+
[docs]classCmdTeleport(COMMAND_DEFAULT_CLASS):
+ """
+ teleport object to another location
+
+ Usage:
+ tel/switch [<object> to||=] <target location>
+
+ Examples:
+ tel Limbo
+ tel/quiet box = Limbo
+ tel/tonone box
+
+ Switches:
+ quiet - don't echo leave/arrive messages to the source/target
+ locations for the move.
+ intoexit - if target is an exit, teleport INTO
+ the exit object instead of to its destination
+ tonone - if set, teleport the object to a None-location. If this
+ switch is set, <target location> is ignored.
+ Note that the only way to retrieve
+ an object from a None location is by direct #dbref
+ reference. A puppeted object cannot be moved to None.
+ loc - teleport object to the target's location instead of its contents
+
+ Teleports an object somewhere. If no object is given, you yourself are
+ teleported to the target location.
+ """
+
+ key="tel"
+ aliases="teleport"
+ switch_options=("quiet","intoexit","tonone","loc")
+ rhs_split=("="," to ")# Prefer = delimiter, but allow " to " usage.
+ locks="cmd:perm(teleport) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Performs the teleport"""
+
+ caller=self.caller
+ args=self.args
+ lhs,rhs=self.lhs,self.rhs
+ switches=self.switches
+
+ # setting switches
+ tel_quietly="quiet"inswitches
+ to_none="tonone"inswitches
+ to_loc="loc"inswitches
+
+ ifto_none:
+ # teleporting to None
+ ifnotargs:
+ obj_to_teleport=caller
+ else:
+ obj_to_teleport=caller.search(lhs,global_search=True)
+ ifnotobj_to_teleport:
+ caller.msg("Did not find object to teleport.")
+ return
+ ifobj_to_teleport.has_account:
+ caller.msg(
+ "Cannot teleport a puppeted object "
+ "(%s, puppeted by %s) to a None-location."
+ %(obj_to_teleport.key,obj_to_teleport.account)
+ )
+ return
+ caller.msg("Teleported %s -> None-location."%obj_to_teleport)
+ ifobj_to_teleport.locationandnottel_quietly:
+ obj_to_teleport.location.msg_contents(
+ "%s teleported %s into nothingness."%(caller,obj_to_teleport),exclude=caller
+ )
+ obj_to_teleport.location=None
+ return
+
+ # not teleporting to None location
+ ifnotargsandnotto_none:
+ caller.msg("Usage: teleport[/switches] [<obj> =] <target_loc>||home")
+ return
+
+ ifrhs:
+ obj_to_teleport=caller.search(lhs,global_search=True)
+ destination=caller.search(rhs,global_search=True)
+ else:
+ obj_to_teleport=caller
+ destination=caller.search(lhs,global_search=True)
+ ifnotobj_to_teleport:
+ caller.msg("Did not find object to teleport.")
+ return
+
+ ifnotdestination:
+ caller.msg("Destination not found.")
+ return
+ ifto_loc:
+ destination=destination.location
+ ifnotdestination:
+ caller.msg("Destination has no location.")
+ return
+ ifobj_to_teleport==destination:
+ caller.msg("You can't teleport an object inside of itself!")
+ return
+ ifobj_to_teleport==destination.location:
+ caller.msg("You can't teleport an object inside something it holds!")
+ return
+ ifobj_to_teleport.locationandobj_to_teleport.location==destination:
+ caller.msg("%s is already at %s."%(obj_to_teleport,destination))
+ return
+ use_destination=True
+ if"intoexit"inself.switches:
+ use_destination=False
+
+ # try the teleport
+ ifobj_to_teleport.move_to(
+ destination,quiet=tel_quietly,emit_to_obj=caller,use_destination=use_destination
+ ):
+ ifobj_to_teleport==caller:
+ caller.msg("Teleported to %s."%destination)
+ else:
+ caller.msg("Teleported %s -> %s."%(obj_to_teleport,destination))
+
+
+
[docs]classCmdScript(COMMAND_DEFAULT_CLASS):
+ """
+ attach a script to an object
+
+ Usage:
+ script[/switch] <obj> [= script_path or <scriptkey>]
+
+ Switches:
+ start - start all non-running scripts on object, or a given script only
+ stop - stop all scripts on objects, or a given script only
+
+ If no script path/key is given, lists all scripts active on the given
+ object.
+ Script path can be given from the base location for scripts as given in
+ settings. If adding a new script, it will be started automatically
+ (no /start switch is needed). Using the /start or /stop switches on an
+ object without specifying a script key/path will start/stop ALL scripts on
+ the object.
+ """
+
+ key="script"
+ aliases="addscript"
+ switch_options=("start","stop")
+ locks="cmd:perm(script) or perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Do stuff"""
+
+ caller=self.caller
+
+ ifnotself.args:
+ string="Usage: script[/switch] <obj> [= script_path or <script key>]"
+ caller.msg(string)
+ return
+
+ ifnotself.lhs:
+ caller.msg("To create a global script you need |wscripts/add <typeclass>|n.")
+ return
+
+ obj=caller.search(self.lhs)
+ ifnotobj:
+ return
+
+ result=[]
+ ifnotself.rhs:
+ # no rhs means we want to operate on all scripts
+ scripts=obj.scripts.all()
+ ifnotscripts:
+ result.append("No scripts defined on %s."%obj.get_display_name(caller))
+ elifnotself.switches:
+ # view all scripts
+ fromevennia.commands.default.systemimportScriptEvMore
+
+ ScriptEvMore(self.caller,scripts.order_by("id"),session=self.session)
+ return
+ elif"start"inself.switches:
+ num=sum([obj.scripts.start(script.key)forscriptinscripts])
+ result.append("%s scripts started on %s."%(num,obj.get_display_name(caller)))
+ elif"stop"inself.switches:
+ forscriptinscripts:
+ result.append(
+ "Stopping script %s on %s."
+ %(script.get_display_name(caller),obj.get_display_name(caller))
+ )
+ script.stop()
+ obj.scripts.validate()
+ else:# rhs exists
+ ifnotself.switches:
+ # adding a new script, and starting it
+ ok=obj.scripts.add(self.rhs,autostart=True)
+ ifnotok:
+ result.append(
+ "\nScript %s could not be added and/or started on %s "
+ "(or it started and immediately shut down)."
+ %(self.rhs,obj.get_display_name(caller))
+ )
+ else:
+ result.append(
+ "Script |w%s|n successfully added and started on %s."
+ %(self.rhs,obj.get_display_name(caller))
+ )
+
+ else:
+ paths=[self.rhs]+[
+ "%s.%s"%(prefix,self.rhs)forprefixinsettings.TYPECLASS_PATHS
+ ]
+ if"stop"inself.switches:
+ # we are stopping an already existing script
+ forpathinpaths:
+ ok=obj.scripts.stop(path)
+ ifnotok:
+ result.append("\nScript %s could not be stopped. Does it exist?"%path)
+ else:
+ result=["Script stopped and removed from object."]
+ break
+ if"start"inself.switches:
+ # we are starting an already existing script
+ forpathinpaths:
+ ok=obj.scripts.start(path)
+ ifnotok:
+ result.append("\nScript %s could not be (re)started."%path)
+ else:
+ result=["Script started successfully."]
+ break
+
+ EvMore(caller,"".join(result).strip())
+
+
+
[docs]classCmdTag(COMMAND_DEFAULT_CLASS):
+ """
+ handles the tags of an object
+
+ Usage:
+ tag[/del] <obj> [= <tag>[:<category>]]
+ tag/search <tag>[:<category]
+
+ Switches:
+ search - return all objects with a given Tag
+ del - remove the given tag. If no tag is specified,
+ clear all tags on object.
+
+ Manipulates and lists tags on objects. Tags allow for quick
+ grouping of and searching for objects. If only <obj> is given,
+ list all tags on the object. If /search is used, list objects
+ with the given tag.
+ The category can be used for grouping tags themselves, but it
+ should be used with restrain - tags on their own are usually
+ enough to for most grouping schemes.
+ """
+
+ key="tag"
+ aliases=["tags"]
+ options=("search","del")
+ locks="cmd:perm(tag) or perm(Builder)"
+ help_category="Building"
+ arg_regex=r"(/\w+?(\s|$))|\s|$"
+
+
[docs]deffunc(self):
+ """Implement the tag functionality"""
+
+ ifnotself.args:
+ self.caller.msg("Usage: tag[/switches] <obj> [= <tag>[:<category>]]")
+ return
+ if"search"inself.switches:
+ # search by tag
+ tag=self.args
+ category=None
+ if":"intag:
+ tag,category=[part.strip()forpartintag.split(":",1)]
+ objs=search.search_tag(tag,category=category)
+ nobjs=len(objs)
+ ifnobjs>0:
+ catstr=(
+ " (category: '|w%s|n')"%category
+ ifcategory
+ else(""ifnobjs==1else" (may have different tag categories)")
+ )
+ matchstr=", ".join(o.get_display_name(self.caller)foroinobjs)
+
+ string="Found |w%i|n object%s with tag '|w%s|n'%s:\n%s"%(
+ nobjs,
+ "s"ifnobjs>1else"",
+ tag,
+ catstr,
+ matchstr,
+ )
+ else:
+ string="No objects found with tag '%s%s'."%(
+ tag,
+ " (category: %s)"%categoryifcategoryelse"",
+ )
+ self.caller.msg(string)
+ return
+ if"del"inself.switches:
+ # remove one or all tags
+ obj=self.caller.search(self.lhs,global_search=True)
+ ifnotobj:
+ return
+ ifself.rhs:
+ # remove individual tag
+ tag=self.rhs
+ category=None
+ if":"intag:
+ tag,category=[part.strip()forpartintag.split(":",1)]
+ ifobj.tags.get(tag,category=category):
+ obj.tags.remove(tag,category=category)
+ string="Removed tag '%s'%s from %s."%(
+ tag,
+ " (category: %s)"%categoryifcategoryelse"",
+ obj,
+ )
+ else:
+ string="No tag '%s'%s to delete on %s."%(
+ tag,
+ " (category: %s)"%categoryifcategoryelse"",
+ obj,
+ )
+ else:
+ # no tag specified, clear all tags
+ old_tags=[
+ "%s%s"%(tag," (category: %s)"%categoryifcategoryelse"")
+ fortag,categoryinobj.tags.all(return_key_and_category=True)
+ ]
+ ifold_tags:
+ obj.tags.clear()
+ string="Cleared all tags from %s: %s"%(obj,", ".join(sorted(old_tags)))
+ else:
+ string="No Tags to clear on %s."%obj
+ self.caller.msg(string)
+ return
+ # no search/deletion
+ ifself.rhs:
+ # = is found; command args are of the form obj = tag
+ obj=self.caller.search(self.lhs,global_search=True)
+ ifnotobj:
+ return
+ tag=self.rhs
+ category=None
+ if":"intag:
+ tag,category=[part.strip()forpartintag.split(":",1)]
+ # create the tag
+ obj.tags.add(tag,category=category)
+ string="Added tag '%s'%s to %s."%(
+ tag,
+ " (category: %s)"%categoryifcategoryelse"",
+ obj,
+ )
+ self.caller.msg(string)
+ else:
+ # no = found - list tags on object
+ obj=self.caller.search(self.args,global_search=True)
+ ifnotobj:
+ return
+ tagtuples=obj.tags.all(return_key_and_category=True)
+ ntags=len(tagtuples)
+ tags=[tup[0]fortupintagtuples]
+ categories=[" (category: %s)"%tup[1]iftup[1]else""fortupintagtuples]
+ ifntags:
+ string="Tag%s on %s: %s"%(
+ "s"ifntags>1else"",
+ obj,
+ ", ".join(sorted("'%s'%s"%(tags[i],categories[i])foriinrange(ntags))),
+ )
+ else:
+ string="No tags attached to %s."%obj
+ self.caller.msg(string)
+
+
+# helper functions for spawn
+
+
+
[docs]classCmdSpawn(COMMAND_DEFAULT_CLASS):
+ """
+ spawn objects from prototype
+
+ Usage:
+ spawn[/noloc] <prototype_key>
+ spawn[/noloc] <prototype_dict>
+
+ spawn/search [prototype_keykey][;tag[,tag]]
+ spawn/list [tag, tag, ...]
+ spawn/list modules - list only module-based prototypes
+ spawn/show [<prototype_key>]
+ spawn/update <prototype_key>
+
+ spawn/save <prototype_dict>
+ spawn/edit [<prototype_key>]
+ olc - equivalent to spawn/edit
+
+ Switches:
+ noloc - allow location to be None if not specified explicitly. Otherwise,
+ location will default to caller's current location.
+ search - search prototype by name or tags.
+ list - list available prototypes, optionally limit by tags.
+ show, examine - inspect prototype by key. If not given, acts like list.
+ raw - show the raw dict of the prototype as a one-line string for manual editing.
+ save - save a prototype to the database. It will be listable by /list.
+ delete - remove a prototype from database, if allowed to.
+ update - find existing objects with the same prototype_key and update
+ them with latest version of given prototype. If given with /save,
+ will auto-update all objects with the old version of the prototype
+ without asking first.
+ edit, menu, olc - create/manipulate prototype in a menu interface.
+
+ Example:
+ spawn GOBLIN
+ spawn {"key":"goblin", "typeclass":"monster.Monster", "location":"#2"}
+ spawn/save {"key": "grunt", prototype: "goblin"};;mobs;edit:all()
+ \f
+ Dictionary keys:
+ |wprototype_parent |n - name of parent prototype to use. Required if typeclass is
+ not set. Can be a path or a list for multiple inheritance (inherits
+ left to right). If set one of the parents must have a typeclass.
+ |wtypeclass |n - string. Required if prototype_parent is not set.
+ |wkey |n - string, the main object identifier
+ |wlocation |n - this should be a valid object or #dbref
+ |whome |n - valid object or #dbref
+ |wdestination|n - only valid for exits (object or dbref)
+ |wpermissions|n - string or list of permission strings
+ |wlocks |n - a lock-string
+ |waliases |n - string or list of strings.
+ |wndb_|n<name> - value of a nattribute (ndb_ is stripped)
+
+ |wprototype_key|n - name of this prototype. Unique. Used to store/retrieve from db
+ and update existing prototyped objects if desired.
+ |wprototype_desc|n - desc of this prototype. Used in listings
+ |wprototype_locks|n - locks of this prototype. Limits who may use prototype
+ |wprototype_tags|n - tags of this prototype. Used to find prototype
+
+ any other keywords are interpreted as Attributes and their values.
+
+ The available prototypes are defined globally in modules set in
+ settings.PROTOTYPE_MODULES. If spawn is used without arguments it
+ displays a list of available prototypes.
+
+ """
+
+ key="spawn"
+ aliases=["olc"]
+ switch_options=(
+ "noloc",
+ "search",
+ "list",
+ "show",
+ "raw",
+ "examine",
+ "save",
+ "delete",
+ "menu",
+ "olc",
+ "update",
+ "edit",
+ )
+ locks="cmd:perm(spawn) or perm(Builder)"
+ help_category="Building"
+
+ def_search_prototype(self,prototype_key,quiet=False):
+ """
+ Search for prototype and handle no/multi-match and access.
+
+ Returns a single found prototype or None - in the
+ case, the caller has already been informed of the
+ search error we need not do any further action.
+
+ """
+ prototypes=protlib.search_prototype(prototype_key)
+ nprots=len(prototypes)
+
+ # handle the search result
+ err=None
+ ifnotprototypes:
+ err=f"No prototype named '{prototype_key}' was found."
+ elifnprots>1:
+ err="Found {} prototypes matching '{}':\n{}".format(
+ nprots,
+ prototype_key,
+ ", ".join(proto.get("prototype_key","")forprotoinprototypes),
+ )
+ else:
+ # we have a single prototype, check access
+ prototype=prototypes[0]
+ ifnotself.caller.locks.check_lockstring(
+ self.caller,prototype.get("prototype_locks",""),access_type="spawn",default=True
+ ):
+ err="You don't have access to use this prototype."
+
+ iferr:
+ # return None on any error
+ ifnotquiet:
+ self.caller.msg(err)
+ return
+ returnprototype
+
+ def_parse_prototype(self,inp,expect=dict):
+ """
+ Parse a prototype dict or key from the input and convert it safely
+ into a dict if appropriate.
+
+ Args:
+ inp (str): The input from user.
+ expect (type, optional):
+ Returns:
+ prototype (dict, str or None): The parsed prototype. If None, the error
+ was already reported.
+
+ """
+ eval_err=None
+ try:
+ prototype=_LITERAL_EVAL(inp)
+ except(SyntaxError,ValueError)aserr:
+ # treat as string
+ eval_err=err
+ prototype=utils.to_str(inp)
+ finally:
+ # it's possible that the input was a prototype-key, in which case
+ # it's okay for the LITERAL_EVAL to fail. Only if the result does not
+ # match the expected type do we have a problem.
+ ifnotisinstance(prototype,expect):
+ ifeval_err:
+ string=(
+ f"{inp}\n{eval_err}\n|RCritical Python syntax error in argument. Only primitive "
+ "Python structures are allowed. \nMake sure to use correct "
+ "Python syntax. Remember especially to put quotes around all "
+ "strings inside lists and dicts.|n For more advanced uses, embed "
+ "inlinefuncs in the strings."
+ )
+ else:
+ string="Expected {}, got {}.".format(expect,type(prototype))
+ self.caller.msg(string)
+ return
+
+ ifexpect==dict:
+ # an actual prototype. We need to make sure it's safe,
+ # so don't allow exec.
+ # TODO: Exec support is deprecated. Remove completely for 1.0.
+ if"exec"inprototypeandnotself.caller.check_permstring("Developer"):
+ self.caller.msg(
+ "Spawn aborted: You are not allowed to ""use the 'exec' prototype key."
+ )
+ return
+ try:
+ # we homogenize the protoype first, to be more lenient with free-form
+ protlib.validate_prototype(protlib.homogenize_prototype(prototype))
+ exceptRuntimeErroraserr:
+ self.caller.msg(str(err))
+ return
+ returnprototype
+
+ def_get_prototype_detail(self,query=None,prototypes=None):
+ """
+ Display the detailed specs of one or more prototypes.
+
+ Args:
+ query (str, optional): If this is given and `prototypes` is not, search for
+ the prototype(s) by this query. This may be a partial query which
+ may lead to multiple matches, all being displayed.
+ prototypes (list, optional): If given, ignore `query` and only show these
+ prototype-details.
+ Returns:
+ display (str, None): A formatted string of one or more prototype details.
+ If None, the caller was already informed of the error.
+
+
+ """
+ ifnotprototypes:
+ # we need to query. Note that if query is None, all prototypes will
+ # be returned.
+ prototypes=protlib.search_prototype(key=query)
+ ifprototypes:
+ return"\n".join(protlib.prototype_to_str(prot)forprotinprototypes)
+ elifquery:
+ self.caller.msg(f"No prototype named '{query}' was found.")
+ else:
+ self.caller.msg("No prototypes found.")
+
+ def_list_prototypes(self,key=None,tags=None):
+ """Display prototypes as a list, optionally limited by key/tags. """
+ protlib.list_prototypes(self.caller,key=key,tags=tags,session=self.session)
+
+ @interactive
+ def_update_existing_objects(self,caller,prototype_key,quiet=False):
+ """
+ Update existing objects (if any) with this prototype-key to the latest
+ prototype version.
+
+ Args:
+ caller (Object): This is necessary for @interactive to work.
+ prototype_key (str): The prototype to update.
+ quiet (bool, optional): If set, don't report to user if no
+ old objects were found to update.
+ Returns:
+ n_updated (int): Number of updated objects.
+
+ """
+ prototype=self._search_prototype(prototype_key)
+ ifnotprototype:
+ return
+
+ existing_objects=protlib.search_objects_with_prototype(prototype_key)
+ ifnotexisting_objects:
+ ifnotquiet:
+ caller.msg("No existing objects found with an older version of this prototype.")
+ return
+
+ ifexisting_objects:
+ n_existing=len(existing_objects)
+ slow=" (note that this may be slow)"ifn_existing>10else""
+ string=(
+ f"There are {n_existing} existing object(s) with an older version "
+ f"of prototype '{prototype_key}'. Should it be re-applied to them{slow}? [Y]/N"
+ )
+ answer=yield(string)
+ ifanswer.lower()in["n","no"]:
+ caller.msg(
+ "|rNo update was done of existing objects. "
+ "Use spawn/update <key> to apply later as needed.|n"
+ )
+ return
+ try:
+ n_updated=spawner.batch_update_objects_with_prototype(
+ prototype,objects=existing_objects
+ )
+ exceptException:
+ logger.log_trace()
+ caller.msg(f"{n_updated} objects were updated.")
+ return
+
+ def_parse_key_desc_tags(self,argstring,desc=True):
+ """
+ Parse ;-separated input list.
+ """
+ key,desc,tags="","",[]
+ if";"inargstring:
+ parts=[part.strip().lower()forpartinargstring.split(";")]
+ iflen(parts)>1anddesc:
+ key=parts[0]
+ desc=parts[1]
+ tags=parts[2:]
+ else:
+ key=parts[0]
+ tags=parts[1:]
+ else:
+ key=argstring.strip().lower()
+ returnkey,desc,tags
+
+
[docs]deffunc(self):
+ """Implements the spawner"""
+
+ caller=self.caller
+ noloc="noloc"inself.switches
+
+ # run the menu/olc
+ if(
+ self.cmdstring=="olc"
+ or"menu"inself.switches
+ or"olc"inself.switches
+ or"edit"inself.switches
+ ):
+ # OLC menu mode
+ prototype=None
+ ifself.lhs:
+ prototype_key=self.lhs
+ prototype=self._search_prototype(prototype_key)
+ ifnotprototype:
+ return
+ olc_menus.start_olc(caller,session=self.session,prototype=prototype)
+ return
+
+ if"search"inself.switches:
+ # query for a key match. The arg is a search query or nothing.
+
+ ifnotself.args:
+ # an empty search returns the full list
+ self._list_prototypes()
+ return
+
+ # search for key;tag combinations
+ key,_,tags=self._parse_key_desc_tags(self.args,desc=False)
+ self._list_prototypes(key,tags)
+ return
+
+ if"raw"inself.switches:
+ # query for key match and return the prototype as a safe one-liner string.
+ ifnotself.args:
+ caller.msg("You need to specify a prototype-key to get the raw data for.")
+ prototype=self._search_prototype(self.args)
+ ifnotprototype:
+ return
+ caller.msg(str(prototype))
+ return
+
+ if"show"inself.switchesor"examine"inself.switches:
+ # show a specific prot detail. The argument is a search query or empty.
+ ifnotself.args:
+ # we don't show the list of all details, that's too spammy.
+ caller.msg("You need to specify a prototype-key to show.")
+ return
+
+ detail_string=self._get_prototype_detail(self.args)
+ ifnotdetail_string:
+ return
+ caller.msg(detail_string)
+ return
+
+ if"list"inself.switches:
+ # for list, all optional arguments are tags.
+ tags=self.lhslist
+ err=self._list_prototypes(tags=tags)
+ iferr:
+ caller.msg(
+ "No prototypes found with prototype-tag(s): {}".format(
+ list_to_string(tags,"or")
+ )
+ )
+ return
+
+ if"save"inself.switches:
+ # store a prototype to the database store
+ ifnotself.args:
+ caller.msg(
+ "Usage: spawn/save [<key>[;desc[;tag,tag[,...][;lockstring]]]] = <prototype_dict>"
+ )
+ return
+ ifself.rhs:
+ # input on the form key = prototype
+ prototype_key,prototype_desc,prototype_tags=self._parse_key_desc_tags(self.lhs)
+ prototype_key=Noneifnotprototype_keyelseprototype_key
+ prototype_desc=Noneifnotprototype_descelseprototype_desc
+ prototype_tags=Noneifnotprototype_tagselseprototype_tags
+ prototype_input=self.rhs.strip()
+ else:
+ prototype_key=prototype_desc=None
+ prototype_tags=None
+ prototype_input=self.lhs.strip()
+
+ # handle parsing
+ prototype=self._parse_prototype(prototype_input)
+ ifnotprototype:
+ return
+
+ prot_prototype_key=prototype.get("prototype_key")
+
+ ifnot(prototype_keyorprot_prototype_key):
+ caller.msg(
+ "A prototype_key must be given, either as `prototype_key = <prototype>` "
+ "or as a key 'prototype_key' inside the prototype structure."
+ )
+ return
+
+ ifprototype_keyisNone:
+ prototype_key=prot_prototype_key
+
+ ifprot_prototype_key!=prototype_key:
+ caller.msg("(Replacing `prototype_key` in prototype with given key.)")
+ prototype["prototype_key"]=prototype_key
+
+ ifprototype_descisnotNoneandprot_prototype_key!=prototype_desc:
+ caller.msg("(Replacing `prototype_desc` in prototype with given desc.)")
+ prototype["prototype_desc"]=prototype_desc
+ ifprototype_tagsisnotNoneandprototype.get("prototype_tags")!=prototype_tags:
+ caller.msg("(Replacing `prototype_tags` in prototype with given tag(s))")
+ prototype["prototype_tags"]=prototype_tags
+
+ string=""
+ # check for existing prototype (exact match)
+ old_prototype=self._search_prototype(prototype_key,quiet=True)
+
+ diff=spawner.prototype_diff(old_prototype,prototype,homogenize=True)
+ diffstr=spawner.format_diff(diff)
+ new_prototype_detail=self._get_prototype_detail(prototypes=[prototype])
+
+ ifold_prototype:
+ ifnotdiffstr:
+ string=f"|yAlready existing Prototype:|n\n{new_prototype_detail}\n"
+ question=(
+ "\nThere seems to be no changes. Do you still want to (re)save? [Y]/N"
+ )
+ else:
+ string=(
+ f'|yExisting prototype "{prototype_key}" found. Change:|n\n{diffstr}\n'
+ f"|yNew changed prototype:|n\n{new_prototype_detail}"
+ )
+ question=(
+ "\n|yDo you want to apply the change to the existing prototype?|n [Y]/N"
+ )
+ else:
+ string=f"|yCreating new prototype:|n\n{new_prototype_detail}"
+ question="\nDo you want to continue saving? [Y]/N"
+
+ answer=yield(string+question)
+ ifanswer.lower()in["n","no"]:
+ caller.msg("|rSave cancelled.|n")
+ return
+
+ # all seems ok. Try to save.
+ try:
+ prot=protlib.save_prototype(prototype)
+ ifnotprot:
+ caller.msg("|rError saving:|R {}.|n".format(prototype_key))
+ return
+ exceptprotlib.PermissionErroraserr:
+ caller.msg("|rError saving:|R {}|n".format(err))
+ return
+ caller.msg("|gSaved prototype:|n {}".format(prototype_key))
+
+ # check if we want to update existing objects
+
+ self._update_existing_objects(self.caller,prototype_key,quiet=True)
+ return
+
+ ifnotself.args:
+ # all switches beyond this point gets a common non-arg return
+ ncount=len(protlib.search_prototype())
+ caller.msg(
+ "Usage: spawn <prototype-key> or {{key: value, ...}}"
+ f"\n ({ncount} existing prototypes. Use /list to inspect)"
+ )
+ return
+
+ if"delete"inself.switches:
+ # remove db-based prototype
+ prototype_detail=self._get_prototype_detail(self.args)
+ ifnotprototype_detail:
+ return
+
+ string=f"|rDeleting prototype:|n\n{prototype_detail}"
+ question="\nDo you want to continue deleting? [Y]/N"
+ answer=yield(string+question)
+ ifanswer.lower()in["n","no"]:
+ caller.msg("|rDeletion cancelled.|n")
+ return
+
+ try:
+ success=protlib.delete_prototype(self.args)
+ exceptprotlib.PermissionErroraserr:
+ retmsg=f"|rError deleting:|R {err}|n"
+ else:
+ retmsg=(
+ "Deletion successful"
+ ifsuccess
+ else"Deletion failed (does the prototype exist?)"
+ )
+ caller.msg(retmsg)
+ return
+
+ if"update"inself.switches:
+ # update existing prototypes
+ prototype_key=self.args.strip().lower()
+ self._update_existing_objects(self.caller,prototype_key)
+ return
+
+ # If we get to this point, we use not switches but are trying a
+ # direct creation of an object from a given prototype or -key
+
+ prototype=self._parse_prototype(
+ self.args,expect=dictifself.args.strip().startswith("{")elsestr
+ )
+ ifnotprototype:
+ # this will only let through dicts or strings
+ return
+
+ key="<unnamed>"
+ ifisinstance(prototype,str):
+ # A prototype key we are looking to apply
+ prototype_key=prototype
+ prototype=self._search_prototype(prototype_key)
+
+ ifnotprototype:
+ return
+
+ # proceed to spawning
+ try:
+ forobjinspawner.spawn(prototype):
+ self.caller.msg("Spawned %s."%obj.get_display_name(self.caller))
+ ifnotprototype.get("location")andnotnoloc:
+ # we don't hardcode the location in the prototype (unless the user
+ # did so manually) - that would lead to it having to be 'removed' every
+ # time we try to update objects with this prototype in the future.
+ obj.location=caller.location
+ exceptRuntimeErroraserr:
+ caller.msg(err)
Source code for evennia.commands.default.cmdset_account
+"""
+
+This is the cmdset for Account (OOC) commands. These are
+stored on the Account object and should thus be able to handle getting
+an Account object as caller rather than a Character.
+
+Note - in order for session-rerouting (in MULTISESSION_MODE=2) to
+function, all commands in this cmdset should use the self.msg()
+command method rather than caller.msg().
+"""
+
+fromevennia.commands.cmdsetimportCmdSet
+fromevennia.commands.defaultimporthelp,comms,admin,system
+fromevennia.commands.defaultimportbuilding,account,general
+
+
+
Source code for evennia.commands.default.cmdset_character
+"""
+This module ties together all the commands default Character objects have
+available (i.e. IC commands). Note that some commands, such as
+communication-commands are instead put on the account level, in the
+Account cmdset. Account commands remain available also to Characters.
+"""
+fromevennia.commands.cmdsetimportCmdSet
+fromevennia.commands.defaultimportgeneral,help,admin,system
+fromevennia.commands.defaultimportbuilding
+fromevennia.commands.defaultimportbatchprocess
+
+
+
Source code for evennia.commands.default.cmdset_unloggedin
+"""
+This module describes the unlogged state of the default game.
+The setting STATE_UNLOGGED should be set to the python path
+of the state instance in this module.
+"""
+fromevennia.commands.cmdsetimportCmdSet
+fromevennia.commands.defaultimportunloggedin
+
+
+
[docs]classUnloggedinCmdSet(CmdSet):
+ """
+ Sets up the unlogged cmdset.
+ """
+
+ key="DefaultUnloggedin"
+ priority=0
+
+
+"""
+Comsystem command module.
+
+Comm commands are OOC commands and intended to be made available to
+the Account at all times (they go into the AccountCmdSet). So we
+make sure to homogenize self.caller to always be the account object
+for easy handling.
+
+"""
+importhashlib
+importtime
+fromdjango.confimportsettings
+fromevennia.comms.modelsimportChannelDB,Msg
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.accountsimportbots
+fromevennia.comms.channelhandlerimportCHANNELHANDLER
+fromevennia.locks.lockhandlerimportLockException
+fromevennia.utilsimportcreate,logger,utils,evtable
+fromevennia.utils.utilsimportmake_iter,class_from_module
+
+COMMAND_DEFAULT_CLASS=class_from_module(settings.COMMAND_DEFAULT_CLASS)
+CHANNEL_DEFAULT_TYPECLASS=class_from_module(
+ settings.BASE_CHANNEL_TYPECLASS,fallback=settings.FALLBACK_CHANNEL_TYPECLASS)
+
+
+# limit symbol import for API
+__all__=(
+ "CmdAddCom",
+ "CmdDelCom",
+ "CmdAllCom",
+ "CmdChannels",
+ "CmdCdestroy",
+ "CmdCBoot",
+ "CmdCemit",
+ "CmdCWho",
+ "CmdChannelCreate",
+ "CmdClock",
+ "CmdCdesc",
+ "CmdPage",
+ "CmdIRC2Chan",
+ "CmdIRCStatus",
+ "CmdRSS2Chan",
+ "CmdGrapevine2Chan",
+)
+_DEFAULT_WIDTH=settings.CLIENT_DEFAULT_WIDTH
+
+
+deffind_channel(caller,channelname,silent=False,noaliases=False):
+ """
+ Helper function for searching for a single channel with
+ some error handling.
+ """
+ channels=CHANNEL_DEFAULT_TYPECLASS.objects.channel_search(channelname)
+ ifnotchannels:
+ ifnotnoaliases:
+ channels=[
+ chan
+ forchaninCHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels()
+ ifchannelnameinchan.aliases.all()
+ ]
+ ifchannels:
+ returnchannels[0]
+ ifnotsilent:
+ caller.msg("Channel '%s' not found."%channelname)
+ returnNone
+ eliflen(channels)>1:
+ matches=", ".join(["%s(%s)"%(chan.key,chan.id)forchaninchannels])
+ ifnotsilent:
+ caller.msg("Multiple channels match (be more specific): \n%s"%matches)
+ returnNone
+ returnchannels[0]
+
+
+
[docs]classCmdAddCom(COMMAND_DEFAULT_CLASS):
+ """
+ add a channel alias and/or subscribe to a channel
+
+ Usage:
+ addcom [alias=] <channel>
+
+ Joins a given channel. If alias is given, this will allow you to
+ refer to the channel by this alias rather than the full channel
+ name. Subsequent calls of this command can be used to add multiple
+ aliases to an already joined channel.
+ """
+
+ key="addcom"
+ aliases=["aliaschan","chanalias"]
+ help_category="Comms"
+ locks="cmd:not pperm(channel_banned)"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """Implement the command"""
+
+ caller=self.caller
+ args=self.args
+ account=caller
+
+ ifnotargs:
+ self.msg("Usage: addcom [alias =] channelname.")
+ return
+
+ ifself.rhs:
+ # rhs holds the channelname
+ channelname=self.rhs
+ alias=self.lhs
+ else:
+ channelname=self.args
+ alias=None
+
+ channel=find_channel(caller,channelname)
+ ifnotchannel:
+ # we use the custom search method to handle errors.
+ return
+
+ # check permissions
+ ifnotchannel.access(account,"listen"):
+ self.msg("%s: You are not allowed to listen to this channel."%channel.key)
+ return
+
+ string=""
+ ifnotchannel.has_connection(account):
+ # we want to connect as well.
+ ifnotchannel.connect(account):
+ # if this would have returned True, the account is connected
+ self.msg("%s: You are not allowed to join this channel."%channel.key)
+ return
+ else:
+ string+="You now listen to the channel %s. "%channel.key
+ else:
+ ifchannel.unmute(account):
+ string+="You unmute channel %s."%channel.key
+ else:
+ string+="You are already connected to channel %s."%channel.key
+
+ ifalias:
+ # create a nick and add it to the caller.
+ caller.nicks.add(alias,channel.key,category="channel")
+ string+=" You can now refer to the channel %s with the alias '%s'."
+ self.msg(string%(channel.key,alias))
+ else:
+ string+=" No alias added."
+ self.msg(string)
+
+
+
[docs]classCmdDelCom(COMMAND_DEFAULT_CLASS):
+ """
+ remove a channel alias and/or unsubscribe from channel
+
+ Usage:
+ delcom <alias or channel>
+ delcom/all <channel>
+
+ If the full channel name is given, unsubscribe from the
+ channel. If an alias is given, remove the alias but don't
+ unsubscribe. If the 'all' switch is used, remove all aliases
+ for that channel.
+ """
+
+ key="delcom"
+ aliases=["delaliaschan","delchanalias"]
+ help_category="Comms"
+ locks="cmd:not perm(channel_banned)"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """Implementing the command. """
+
+ caller=self.caller
+ account=caller
+
+ ifnotself.args:
+ self.msg("Usage: delcom <alias or channel>")
+ return
+ ostring=self.args.lower()
+
+ channel=find_channel(caller,ostring,silent=True,noaliases=True)
+ ifchannel:
+ # we have given a channel name - unsubscribe
+ ifnotchannel.has_connection(account):
+ self.msg("You are not listening to that channel.")
+ return
+ chkey=channel.key.lower()
+ delnicks="all"inself.switches
+ # find all nicks linked to this channel and delete them
+ ifdelnicks:
+ fornickin[
+ nick
+ fornickinmake_iter(caller.nicks.get(category="channel",return_obj=True))
+ ifnickandnick.pkandnick.value[3].lower()==chkey
+ ]:
+ nick.delete()
+ disconnect=channel.disconnect(account)
+ ifdisconnect:
+ wipednicks=" Eventual aliases were removed."ifdelnickselse""
+ self.msg("You stop listening to channel '%s'.%s"%(channel.key,wipednicks))
+ return
+ else:
+ # we are removing a channel nick
+ channame=caller.nicks.get(key=ostring,category="channel")
+ channel=find_channel(caller,channame,silent=True)
+ ifnotchannel:
+ self.msg("No channel with alias '%s' was found."%ostring)
+ else:
+ ifcaller.nicks.get(ostring,category="channel"):
+ caller.nicks.remove(ostring,category="channel")
+ self.msg("Your alias '%s' for channel %s was cleared."%(ostring,channel.key))
+ else:
+ self.msg("You had no such alias defined for this channel.")
+
+
+
[docs]classCmdAllCom(COMMAND_DEFAULT_CLASS):
+ """
+ perform admin operations on all channels
+
+ Usage:
+ allcom [on | off | who | destroy]
+
+ Allows the user to universally turn off or on all channels they are on, as
+ well as perform a 'who' for all channels they are on. Destroy deletes all
+ channels that you control.
+
+ Without argument, works like comlist.
+ """
+
+ key="allcom"
+ locks="cmd: not pperm(channel_banned)"
+ help_category="Comms"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """Runs the function"""
+
+ caller=self.caller
+ args=self.args
+ ifnotargs:
+ self.execute_cmd("channels")
+ self.msg("(Usage: allcom on | off | who | destroy)")
+ return
+
+ ifargs=="on":
+ # get names of all channels available to listen to
+ # and activate them all
+ channels=[
+ chan
+ forchaninCHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels()
+ ifchan.access(caller,"listen")
+ ]
+ forchannelinchannels:
+ self.execute_cmd("addcom %s"%channel.key)
+ elifargs=="off":
+ # get names all subscribed channels and disconnect from them all
+ channels=CHANNEL_DEFAULT_TYPECLASS.objects.get_subscriptions(caller)
+ forchannelinchannels:
+ self.execute_cmd("delcom %s"%channel.key)
+ elifargs=="destroy":
+ # destroy all channels you control
+ channels=[
+ chan
+ forchaninCHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels()
+ ifchan.access(caller,"control")
+ ]
+ forchannelinchannels:
+ self.execute_cmd("cdestroy %s"%channel.key)
+ elifargs=="who":
+ # run a who, listing the subscribers on visible channels.
+ string="\n|CChannel subscriptions|n"
+ channels=[
+ chan
+ forchaninCHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels()
+ ifchan.access(caller,"listen")
+ ]
+ ifnotchannels:
+ string+="No channels."
+ forchannelinchannels:
+ string+="\n|w%s:|n\n%s"%(channel.key,channel.wholist)
+ self.msg(string.strip())
+ else:
+ # wrong input
+ self.msg("Usage: allcom on | off | who | clear")
+
+
+
[docs]classCmdChannels(COMMAND_DEFAULT_CLASS):
+ """
+ list all channels available to you
+
+ Usage:
+ channels
+ clist
+ comlist
+
+ Lists all channels available to you, whether you listen to them or not.
+ Use 'comlist' to only view your current channel subscriptions.
+ Use addcom/delcom to join and leave channels
+ """
+
+ key="channels"
+ aliases=["clist","comlist","chanlist","channellist","all channels"]
+ help_category="Comms"
+ locks="cmd: not pperm(channel_banned)"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """Implement function"""
+
+ caller=self.caller
+
+ # all channels we have available to listen to
+ channels=[
+ chan
+ forchaninCHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels()
+ ifchan.access(caller,"listen")
+ ]
+ ifnotchannels:
+ self.msg("No channels available.")
+ return
+ # all channel we are already subscribed to
+ subs=CHANNEL_DEFAULT_TYPECLASS.objects.get_subscriptions(caller)
+
+ ifself.cmdstring=="comlist":
+ # just display the subscribed channels with no extra info
+ comtable=self.styled_table(
+ "|wchannel|n",
+ "|wmy aliases|n",
+ "|wdescription|n",
+ align="l",
+ maxwidth=_DEFAULT_WIDTH,
+ )
+ forchaninsubs:
+ clower=chan.key.lower()
+ nicks=caller.nicks.get(category="channel",return_obj=True)
+ comtable.add_row(
+ *[
+ "%s%s"
+ %(
+ chan.key,
+ chan.aliases.all()and"(%s)"%",".join(chan.aliases.all())or"",
+ ),
+ "%s"
+ %",".join(
+ nick.db_key
+ fornickinmake_iter(nicks)
+ ifnickandnick.value[3].lower()==clower
+ ),
+ chan.db.desc,
+ ]
+ )
+ self.msg(
+ "\n|wChannel subscriptions|n (use |wchannels|n to list all,"
+ " |waddcom|n/|wdelcom|n to sub/unsub):|n\n%s"%comtable
+ )
+ else:
+ # full listing (of channels caller is able to listen to)
+ comtable=self.styled_table(
+ "|wsub|n",
+ "|wchannel|n",
+ "|wmy aliases|n",
+ "|wlocks|n",
+ "|wdescription|n",
+ maxwidth=_DEFAULT_WIDTH,
+ )
+ forchaninchannels:
+ clower=chan.key.lower()
+ nicks=caller.nicks.get(category="channel",return_obj=True)
+ nicks=nicksor[]
+ ifchannotinsubs:
+ substatus="|rNo|n"
+ elifcallerinchan.mutelist:
+ substatus="|rMuted|n"
+ else:
+ substatus="|gYes|n"
+ comtable.add_row(
+ *[
+ substatus,
+ "%s%s"
+ %(
+ chan.key,
+ chan.aliases.all()and"(%s)"%",".join(chan.aliases.all())or"",
+ ),
+ "%s"
+ %",".join(
+ nick.db_key
+ fornickinmake_iter(nicks)
+ ifnick.value[3].lower()==clower
+ ),
+ str(chan.locks),
+ chan.db.desc,
+ ]
+ )
+ comtable.reformat_column(0,width=9)
+ comtable.reformat_column(3,width=14)
+ self.msg(
+ "\n|wAvailable channels|n (use |wcomlist|n,|waddcom|n and |wdelcom|n"
+ " to manage subscriptions):\n%s"%comtable
+ )
+
+
+
[docs]classCmdCdestroy(COMMAND_DEFAULT_CLASS):
+ """
+ destroy a channel you created
+
+ Usage:
+ cdestroy <channel>
+
+ Destroys a channel that you control.
+ """
+
+ key="cdestroy"
+ help_category="Comms"
+ locks="cmd: not pperm(channel_banned)"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """Destroy objects cleanly."""
+ caller=self.caller
+
+ ifnotself.args:
+ self.msg("Usage: cdestroy <channelname>")
+ return
+ channel=find_channel(caller,self.args)
+ ifnotchannel:
+ self.msg("Could not find channel %s."%self.args)
+ return
+ ifnotchannel.access(caller,"control"):
+ self.msg("You are not allowed to do that.")
+ return
+ channel_key=channel.key
+ message="%s is being destroyed. Make sure to change your aliases."%channel_key
+ msgobj=create.create_message(caller,message,channel)
+ channel.msg(msgobj)
+ channel.delete()
+ CHANNELHANDLER.update()
+ self.msg("Channel '%s' was destroyed."%channel_key)
+ logger.log_sec(
+ "Channel Deleted: %s (Caller: %s, IP: %s)."
+ %(channel_key,caller,self.session.address)
+ )
+
+
+
[docs]classCmdCBoot(COMMAND_DEFAULT_CLASS):
+ """
+ kick an account from a channel you control
+
+ Usage:
+ cboot[/quiet] <channel> = <account> [:reason]
+
+ Switch:
+ quiet - don't notify the channel
+
+ Kicks an account or object from a channel you control.
+
+ """
+
+ key="cboot"
+ switch_options=("quiet",)
+ locks="cmd: not pperm(channel_banned)"
+ help_category="Comms"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]classCmdCemit(COMMAND_DEFAULT_CLASS):
+ """
+ send an admin message to a channel you control
+
+ Usage:
+ cemit[/switches] <channel> = <message>
+
+ Switches:
+ sendername - attach the sender's name before the message
+ quiet - don't echo the message back to sender
+
+ Allows the user to broadcast a message over a channel as long as
+ they control it. It does not show the user's name unless they
+ provide the /sendername switch.
+
+ """
+
+ key="cemit"
+ aliases=["cmsg"]
+ switch_options=("sendername","quiet")
+ locks="cmd: not pperm(channel_banned) and pperm(Player)"
+ help_category="Comms"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]classCmdCWho(COMMAND_DEFAULT_CLASS):
+ """
+ show who is listening to a channel
+
+ Usage:
+ cwho <channel>
+
+ List who is connected to a given channel you have access to.
+ """
+
+ key="cwho"
+ locks="cmd: not pperm(channel_banned)"
+ help_category="Comms"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]classCmdChannelCreate(COMMAND_DEFAULT_CLASS):
+ """
+ create a new channel
+
+ Usage:
+ ccreate <new channel>[;alias;alias...] = description
+
+ Creates a new channel owned by you.
+ """
+
+ key="ccreate"
+ aliases="channelcreate"
+ locks="cmd:not pperm(channel_banned) and pperm(Player)"
+ help_category="Comms"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """Implement the command"""
+
+ caller=self.caller
+
+ ifnotself.args:
+ self.msg("Usage ccreate <channelname>[;alias;alias..] = description")
+ return
+
+ description=""
+
+ ifself.rhs:
+ description=self.rhs
+ lhs=self.lhs
+ channame=lhs
+ aliases=None
+ if";"inlhs:
+ channame,aliases=lhs.split(";",1)
+ aliases=[alias.strip().lower()foraliasinaliases.split(";")]
+ channel=CHANNEL_DEFAULT_TYPECLASS.objects.channel_search(channame)
+ ifchannel:
+ self.msg("A channel with that name already exists.")
+ return
+ # Create and set the channel up
+ lockstring="send:all();listen:all();control:id(%s)"%caller.id
+ new_chan=create.create_channel(channame.strip(),aliases,description,locks=lockstring)
+ new_chan.connect(caller)
+ CHANNELHANDLER.update()
+ self.msg("Created channel %s and connected to it."%new_chan.key)
+
+
+
[docs]classCmdClock(COMMAND_DEFAULT_CLASS):
+ """
+ change channel locks of a channel you control
+
+ Usage:
+ clock <channel> [= <lockstring>]
+
+ Changes the lock access restrictions of a channel. If no
+ lockstring was given, view the current lock definitions.
+ """
+
+ key="clock"
+ locks="cmd:not pperm(channel_banned)"
+ aliases=["clock"]
+ help_category="Comms"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """run the function"""
+
+ ifnotself.args:
+ string="Usage: clock channel [= lockstring]"
+ self.msg(string)
+ return
+
+ channel=find_channel(self.caller,self.lhs)
+ ifnotchannel:
+ return
+ ifnotself.rhs:
+ # no =, so just view the current locks
+ string="Current locks on %s:"%channel.key
+ string="%s\n%s"%(string,channel.locks)
+ self.msg(string)
+ return
+ # we want to add/change a lock.
+ ifnotchannel.access(self.caller,"control"):
+ string="You don't control this channel."
+ self.msg(string)
+ return
+ # Try to add the lock
+ try:
+ channel.locks.add(self.rhs)
+ exceptLockExceptionaserr:
+ self.msg(err)
+ return
+ string="Lock(s) applied. "
+ string+="Current locks on %s:"%channel.key
+ string="%s\n%s"%(string,channel.locks)
+ self.msg(string)
+
+
+
[docs]classCmdCdesc(COMMAND_DEFAULT_CLASS):
+ """
+ describe a channel you control
+
+ Usage:
+ cdesc <channel> = <description>
+
+ Changes the description of the channel as shown in
+ channel lists.
+ """
+
+ key="cdesc"
+ locks="cmd:not pperm(channel_banned)"
+ help_category="Comms"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """Implement command"""
+
+ caller=self.caller
+
+ ifnotself.rhs:
+ self.msg("Usage: cdesc <channel> = <description>")
+ return
+ channel=find_channel(caller,self.lhs)
+ ifnotchannel:
+ self.msg("Channel '%s' not found."%self.lhs)
+ return
+ # check permissions
+ ifnotchannel.access(caller,"control"):
+ self.msg("You cannot admin this channel.")
+ return
+ # set the description
+ channel.db.desc=self.rhs
+ channel.save()
+ self.msg("Description of channel '%s' set to '%s'."%(channel.key,self.rhs))
+
+
+
[docs]classCmdPage(COMMAND_DEFAULT_CLASS):
+ """
+ send a private message to another account
+
+ Usage:
+ page[/switches] [<account>,<account>,... = <message>]
+ tell ''
+ page <number>
+
+ Switch:
+ last - shows who you last messaged
+ list - show your last <number> of tells/pages (default)
+
+ Send a message to target user (if online). If no
+ argument is given, you will get a list of your latest messages.
+ """
+
+ key="page"
+ aliases=["tell"]
+ switch_options=("last","list")
+ locks="cmd:not pperm(page_banned)"
+ help_category="Comms"
+
+ # this is used by the COMMAND_DEFAULT_CLASS parent
+ account_caller=True
+
+
[docs]deffunc(self):
+ """Implement function using the Msg methods"""
+
+ # Since account_caller is set above, this will be an Account.
+ caller=self.caller
+
+ # get the messages we've sent (not to channels)
+ pages_we_sent=Msg.objects.get_messages_by_sender(caller,exclude_channel_messages=True)
+ # get last messages we've got
+ pages_we_got=Msg.objects.get_messages_by_receiver(caller)
+
+ if"last"inself.switches:
+ ifpages_we_sent:
+ recv=",".join(obj.keyforobjinpages_we_sent[-1].receivers)
+ self.msg("You last paged |c%s|n:%s"%(recv,pages_we_sent[-1].message))
+ return
+ else:
+ self.msg("You haven't paged anyone yet.")
+ return
+
+ ifnotself.argsornotself.rhs:
+ pages=pages_we_sent+pages_we_got
+ pages=sorted(pages,key=lambdapage:page.date_created)
+
+ number=5
+ ifself.args:
+ try:
+ number=int(self.args)
+ exceptValueError:
+ self.msg("Usage: tell [<account> = msg]")
+ return
+
+ iflen(pages)>number:
+ lastpages=pages[-number:]
+ else:
+ lastpages=pages
+ to_template="|w{date}{clr}{sender}|nto{clr}{receiver}|n:> {message}"
+ from_template="|w{date}{clr}{receiver}|nfrom{clr}{sender}|n:< {message}"
+ listing=[]
+ prev_selfsend=False
+ forpageinlastpages:
+ multi_send=len(page.senders)>1
+ multi_recv=len(page.receivers)>1
+ sending=self.callerinpage.senders
+ # self-messages all look like sends, so we assume they always
+ # come in close pairs and treat the second of the pair as the recv.
+ selfsend=sendingandself.callerinpage.receivers
+ ifselfsend:
+ ifprev_selfsend:
+ # this is actually a receive of a self-message
+ sending=False
+ prev_selfsend=False
+ else:
+ prev_selfsend=True
+
+ clr="|c"ifsendingelse"|g"
+
+ sender=f"|n,{clr}".join(obj.keyforobjinpage.senders)
+ receiver=f"|n,{clr}".join([obj.nameforobjinpage.receivers])
+ ifsending:
+ template=to_template
+ sender=f"{sender} "ifmulti_sendelse""
+ receiver=f" {receiver}"ifmulti_recvelsef" {receiver}"
+ else:
+ template=from_template
+ receiver=f"{receiver} "ifmulti_recvelse""
+ sender=f" {sender} "ifmulti_sendelsef" {sender}"
+
+ listing.append(
+ template.format(
+ date=utils.datetime_format(page.date_created),
+ clr=clr,
+ sender=sender,
+ receiver=receiver,
+ message=page.message,
+ )
+ )
+ lastpages="\n ".join(listing)
+
+ iflastpages:
+ string="Your latest pages:\n%s"%lastpages
+ else:
+ string="You haven't paged anyone yet."
+ self.msg(string)
+ return
+
+ # We are sending. Build a list of targets
+
+ ifnotself.lhs:
+ # If there are no targets, then set the targets
+ # to the last person we paged.
+ ifpages_we_sent:
+ receivers=pages_we_sent[-1].receivers
+ else:
+ self.msg("Who do you want to page?")
+ return
+ else:
+ receivers=self.lhslist
+
+ recobjs=[]
+ forreceiverinset(receivers):
+ ifisinstance(receiver,str):
+ pobj=caller.search(receiver)
+ elifhasattr(receiver,"character"):
+ pobj=receiver
+ else:
+ self.msg("Who do you want to page?")
+ return
+ ifpobj:
+ recobjs.append(pobj)
+ ifnotrecobjs:
+ self.msg("Noone found to page.")
+ return
+
+ header="|wAccount|n |c%s|n |wpages:|n"%caller.key
+ message=self.rhs
+
+ # if message begins with a :, we assume it is a 'page-pose'
+ ifmessage.startswith(":"):
+ message="%s%s"%(caller.key,message.strip(":").strip())
+
+ # create the persistent message object
+ create.create_message(caller,message,receivers=recobjs)
+
+ # tell the accounts they got a message.
+ received=[]
+ rstrings=[]
+ forpobjinrecobjs:
+ ifnotpobj.access(caller,"msg"):
+ rstrings.append("You are not allowed to page %s."%pobj)
+ continue
+ pobj.msg("%s%s"%(header,message))
+ ifhasattr(pobj,"sessions")andnotpobj.sessions.count():
+ received.append("|C%s|n"%pobj.name)
+ rstrings.append(
+ "%s is offline. They will see your message if they list their pages later."
+ %received[-1]
+ )
+ else:
+ received.append("|c%s|n"%pobj.name)
+ ifrstrings:
+ self.msg("\n".join(rstrings))
+ self.msg("You paged %s with: '%s'."%(", ".join(received),message))
+
+
+def_list_bots(cmd):
+ """
+ Helper function to produce a list of all IRC bots.
+
+ Args:
+ cmd (Command): Instance of the Bot command.
+ Returns:
+ bots (str): A table of bots or an error message.
+
+ """
+ ircbots=[
+ botforbotinAccountDB.objects.filter(db_is_bot=True,username__startswith="ircbot-")
+ ]
+ ifircbots:
+ table=cmd.styled_table(
+ "|w#dbref|n",
+ "|wbotname|n",
+ "|wev-channel|n",
+ "|wirc-channel|n",
+ "|wSSL|n",
+ maxwidth=_DEFAULT_WIDTH,
+ )
+ forircbotinircbots:
+ ircinfo="%s (%s:%s)"%(
+ ircbot.db.irc_channel,
+ ircbot.db.irc_network,
+ ircbot.db.irc_port,
+ )
+ table.add_row(
+ "#%i"%ircbot.id,
+ ircbot.db.irc_botname,
+ ircbot.db.ev_channel,
+ ircinfo,
+ ircbot.db.irc_ssl,
+ )
+ returntable
+ else:
+ return"No irc bots found."
+
+
+
[docs]classCmdIRC2Chan(COMMAND_DEFAULT_CLASS):
+ """
+ Link an evennia channel to an external IRC channel
+
+ Usage:
+ irc2chan[/switches] <evennia_channel> = <ircnetwork> <port> <#irchannel> <botname>[:typeclass]
+ irc2chan/delete botname|#dbid
+
+ Switches:
+ /delete - this will delete the bot and remove the irc connection
+ to the channel. Requires the botname or #dbid as input.
+ /remove - alias to /delete
+ /disconnect - alias to /delete
+ /list - show all irc<->evennia mappings
+ /ssl - use an SSL-encrypted connection
+
+ Example:
+ irc2chan myircchan = irc.dalnet.net 6667 #mychannel evennia-bot
+ irc2chan public = irc.freenode.net 6667 #evgaming #evbot:accounts.mybot.MyBot
+
+ This creates an IRC bot that connects to a given IRC network and
+ channel. If a custom typeclass path is given, this will be used
+ instead of the default bot class.
+ The bot will relay everything said in the evennia channel to the
+ IRC channel and vice versa. The bot will automatically connect at
+ server start, so this command need only be given once. The
+ /disconnect switch will permanently delete the bot. To only
+ temporarily deactivate it, use the |wservices|n command instead.
+ Provide an optional bot class path to use a custom bot.
+ """
+
+ key="irc2chan"
+ switch_options=("delete","remove","disconnect","list","ssl")
+ locks="cmd:serversetting(IRC_ENABLED) and pperm(Developer)"
+ help_category="Comms"
+
+
[docs]deffunc(self):
+ """Setup the irc-channel mapping"""
+
+ ifnotsettings.IRC_ENABLED:
+ string="""IRC is not enabled. You need to activate it in game/settings.py."""
+ self.msg(string)
+ return
+
+ if"list"inself.switches:
+ # show all connections
+ self.msg(_list_bots(self))
+ return
+
+ if"disconnect"inself.switchesor"remove"inself.switchesor"delete"inself.switches:
+ botname="ircbot-%s"%self.lhs
+ matches=AccountDB.objects.filter(db_is_bot=True,username=botname)
+ dbref=utils.dbref(self.lhs)
+ ifnotmatchesanddbref:
+ # try dbref match
+ matches=AccountDB.objects.filter(db_is_bot=True,id=dbref)
+ ifmatches:
+ matches[0].delete()
+ self.msg("IRC connection destroyed.")
+ else:
+ self.msg("IRC connection/bot could not be removed, does it exist?")
+ return
+
+ ifnotself.argsornotself.rhs:
+ string=(
+ "Usage: irc2chan[/switches] <evennia_channel> ="
+ " <ircnetwork> <port> <#irchannel> <botname>[:typeclass]"
+ )
+ self.msg(string)
+ return
+
+ channel=self.lhs
+ self.rhs=self.rhs.replace("#"," ")# to avoid Python comment issues
+ try:
+ irc_network,irc_port,irc_channel,irc_botname=[
+ part.strip()forpartinself.rhs.split(None,4)
+ ]
+ irc_channel="#%s"%irc_channel
+ exceptException:
+ string="IRC bot definition '%s' is not valid."%self.rhs
+ self.msg(string)
+ return
+
+ botclass=None
+ if":"inirc_botname:
+ irc_botname,botclass=[part.strip()forpartinirc_botname.split(":",2)]
+ botname="ircbot-%s"%irc_botname
+ # If path given, use custom bot otherwise use default.
+ botclass=botclassifbotclasselsebots.IRCBot
+ irc_ssl="ssl"inself.switches
+
+ # create a new bot
+ bot=AccountDB.objects.filter(username__iexact=botname)
+ ifbot:
+ # re-use an existing bot
+ bot=bot[0]
+ ifnotbot.is_bot:
+ self.msg("Account '%s' already exists and is not a bot."%botname)
+ return
+ else:
+ try:
+ bot=create.create_account(botname,None,None,typeclass=botclass)
+ exceptExceptionaserr:
+ self.msg("|rError, could not create the bot:|n '%s'."%err)
+ return
+ bot.start(
+ ev_channel=channel,
+ irc_botname=irc_botname,
+ irc_channel=irc_channel,
+ irc_network=irc_network,
+ irc_port=irc_port,
+ irc_ssl=irc_ssl,
+ )
+ self.msg("Connection created. Starting IRC bot.")
+
+
+
[docs]classCmdIRCStatus(COMMAND_DEFAULT_CLASS):
+ """
+ Check and reboot IRC bot.
+
+ Usage:
+ ircstatus [#dbref ping||nicklist||reconnect]
+
+ If not given arguments, will return a list of all bots (like
+ irc2chan/list). The 'ping' argument will ping the IRC network to
+ see if the connection is still responsive. The 'nicklist' argument
+ (aliases are 'who' and 'users') will return a list of users on the
+ remote IRC channel. Finally, 'reconnect' will force the client to
+ disconnect and reconnect again. This may be a last resort if the
+ client has silently lost connection (this may happen if the remote
+ network experience network issues). During the reconnection
+ messages sent to either channel will be lost.
+
+ """
+
+ key="ircstatus"
+ locks="cmd:serversetting(IRC_ENABLED) and perm(ircstatus) or perm(Builder))"
+ help_category="Comms"
+
+
[docs]deffunc(self):
+ """Handles the functioning of the command."""
+
+ ifnotself.args:
+ self.msg(_list_bots(self))
+ return
+ # should always be on the form botname option
+ args=self.args.split()
+ iflen(args)!=2:
+ self.msg("Usage: ircstatus [#dbref ping||nicklist||reconnect]")
+ return
+ botname,option=args
+ ifoptionnotin("ping","users","reconnect","nicklist","who"):
+ self.msg("Not a valid option.")
+ return
+ matches=None
+ ifutils.dbref(botname):
+ matches=AccountDB.objects.filter(db_is_bot=True,id=utils.dbref(botname))
+ ifnotmatches:
+ self.msg(
+ "No matching IRC-bot found. Use ircstatus without arguments to list active bots."
+ )
+ return
+ ircbot=matches[0]
+ channel=ircbot.db.irc_channel
+ network=ircbot.db.irc_network
+ port=ircbot.db.irc_port
+ chtext="IRC bot '%s' on channel %s (%s:%s)"%(
+ ircbot.db.irc_botname,
+ channel,
+ network,
+ port,
+ )
+ ifoption=="ping":
+ # check connection by sending outself a ping through the server.
+ self.caller.msg("Pinging through %s."%chtext)
+ ircbot.ping(self.caller)
+ elifoptionin("users","nicklist","who"):
+ # retrieve user list. The bot must handles the echo since it's
+ # an asynchronous call.
+ self.caller.msg("Requesting nicklist from %s (%s:%s)."%(channel,network,port))
+ ircbot.get_nicklist(self.caller)
+ elifself.caller.locks.check_lockstring(
+ self.caller,"dummy:perm(ircstatus) or perm(Developer)"
+ ):
+ # reboot the client
+ self.caller.msg("Forcing a disconnect + reconnect of %s."%chtext)
+ ircbot.reconnect()
+ else:
+ self.caller.msg("You don't have permission to force-reload the IRC bot.")
+
+
+# RSS connection
+
[docs]classCmdRSS2Chan(COMMAND_DEFAULT_CLASS):
+ """
+ link an evennia channel to an external RSS feed
+
+ Usage:
+ rss2chan[/switches] <evennia_channel> = <rss_url>
+
+ Switches:
+ /disconnect - this will stop the feed and remove the connection to the
+ channel.
+ /remove - "
+ /list - show all rss->evennia mappings
+
+ Example:
+ rss2chan rsschan = http://code.google.com/feeds/p/evennia/updates/basic
+
+ This creates an RSS reader that connects to a given RSS feed url. Updates
+ will be echoed as a title and news link to the given channel. The rate of
+ updating is set with the RSS_UPDATE_INTERVAL variable in settings (default
+ is every 10 minutes).
+
+ When disconnecting you need to supply both the channel and url again so as
+ to identify the connection uniquely.
+ """
+
+ key="rss2chan"
+ switch_options=("disconnect","remove","list")
+ locks="cmd:serversetting(RSS_ENABLED) and pperm(Developer)"
+ help_category="Comms"
+
+
[docs]deffunc(self):
+ """Setup the rss-channel mapping"""
+
+ # checking we have all we need
+ ifnotsettings.RSS_ENABLED:
+ string="""RSS is not enabled. You need to activate it in game/settings.py."""
+ self.msg(string)
+ return
+ try:
+ importfeedparser
+
+ assertfeedparser# to avoid checker error of not being used
+ exceptImportError:
+ string=(
+ "RSS requires python-feedparser (https://pypi.python.org/pypi/feedparser)."
+ " Install before continuing."
+ )
+ self.msg(string)
+ return
+
+ if"list"inself.switches:
+ # show all connections
+ rssbots=[
+ bot
+ forbotinAccountDB.objects.filter(db_is_bot=True,username__startswith="rssbot-")
+ ]
+ ifrssbots:
+ table=self.styled_table(
+ "|wdbid|n",
+ "|wupdate rate|n",
+ "|wev-channel",
+ "|wRSS feed URL|n",
+ border="cells",
+ maxwidth=_DEFAULT_WIDTH,
+ )
+ forrssbotinrssbots:
+ table.add_row(
+ rssbot.id,rssbot.db.rss_rate,rssbot.db.ev_channel,rssbot.db.rss_url
+ )
+ self.msg(table)
+ else:
+ self.msg("No rss bots found.")
+ return
+
+ if"disconnect"inself.switchesor"remove"inself.switchesor"delete"inself.switches:
+ botname="rssbot-%s"%self.lhs
+ matches=AccountDB.objects.filter(db_is_bot=True,db_key=botname)
+ ifnotmatches:
+ # try dbref match
+ matches=AccountDB.objects.filter(db_is_bot=True,id=self.args.lstrip("#"))
+ ifmatches:
+ matches[0].delete()
+ self.msg("RSS connection destroyed.")
+ else:
+ self.msg("RSS connection/bot could not be removed, does it exist?")
+ return
+
+ ifnotself.argsornotself.rhs:
+ string="Usage: rss2chan[/switches] <evennia_channel> = <rss url>"
+ self.msg(string)
+ return
+ channel=self.lhs
+ url=self.rhs
+
+ botname="rssbot-%s"%url
+ bot=AccountDB.objects.filter(username__iexact=botname)
+ ifbot:
+ # re-use existing bot
+ bot=bot[0]
+ ifnotbot.is_bot:
+ self.msg("Account '%s' already exists and is not a bot."%botname)
+ return
+ else:
+ # create a new bot
+ bot=create.create_account(botname,None,None,typeclass=bots.RSSBot)
+ bot.start(ev_channel=channel,rss_url=url,rss_rate=10)
+ self.msg("RSS reporter created. Fetching RSS.")
+
+
+
[docs]classCmdGrapevine2Chan(COMMAND_DEFAULT_CLASS):
+ """
+ Link an Evennia channel to an exteral Grapevine channel
+
+ Usage:
+ grapevine2chan[/switches] <evennia_channel> = <grapevine_channel>
+ grapevine2chan/disconnect <connection #id>
+
+ Switches:
+ /list - (or no switch): show existing grapevine <-> Evennia
+ mappings and available grapevine chans
+ /remove - alias to disconnect
+ /delete - alias to disconnect
+
+ Example:
+ grapevine2chan mygrapevine = gossip
+
+ This creates a link between an in-game Evennia channel and an external
+ Grapevine channel. The game must be registered with the Grapevine network
+ (register at https://grapevine.haus) and the GRAPEVINE_* auth information
+ must be added to game settings.
+ """
+
+ key="grapevine2chan"
+ switch_options=("disconnect","remove","delete","list")
+ locks="cmd:serversetting(GRAPEVINE_ENABLED) and pperm(Developer)"
+ help_category="Comms"
+
+
+"""
+General Character commands usually available to all characters
+"""
+importre
+fromdjango.confimportsettings
+fromevennia.utilsimportutils,evtable
+fromevennia.typeclasses.attributesimportNickTemplateInvalid
+
+COMMAND_DEFAULT_CLASS=utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# limit symbol import for API
+__all__=(
+ "CmdHome",
+ "CmdLook",
+ "CmdNick",
+ "CmdInventory",
+ "CmdSetDesc",
+ "CmdGet",
+ "CmdDrop",
+ "CmdGive",
+ "CmdSay",
+ "CmdWhisper",
+ "CmdPose",
+ "CmdAccess",
+)
+
+
+
[docs]classCmdHome(COMMAND_DEFAULT_CLASS):
+ """
+ move to your character's home location
+
+ Usage:
+ home
+
+ Teleports you to your home location.
+ """
+
+ key="home"
+ locks="cmd:perm(home) or perm(Builder)"
+ arg_regex=r"$"
+
+
[docs]deffunc(self):
+ """Implement the command"""
+ caller=self.caller
+ home=caller.home
+ ifnothome:
+ caller.msg("You have no home!")
+ elifhome==caller.location:
+ caller.msg("You are already home!")
+ else:
+ caller.msg("There's no place like home ...")
+ caller.move_to(home)
+
+
+
[docs]classCmdLook(COMMAND_DEFAULT_CLASS):
+ """
+ look at location or object
+
+ Usage:
+ look
+ look <obj>
+ look *<account>
+
+ Observes your location or objects in your vicinity.
+ """
+
+ key="look"
+ aliases=["l","ls"]
+ locks="cmd:all()"
+ arg_regex=r"\s|$"
+
+
[docs]deffunc(self):
+ """
+ Handle the looking.
+ """
+ caller=self.caller
+ ifnotself.args:
+ target=caller.location
+ ifnottarget:
+ caller.msg("You have no location to look at!")
+ return
+ else:
+ target=caller.search(self.args)
+ ifnottarget:
+ return
+ self.msg((caller.at_look(target),{"type":"look"}),options=None)
+
+
+
[docs]classCmdNick(COMMAND_DEFAULT_CLASS):
+ """
+ define a personal alias/nick by defining a string to
+ match and replace it with another on the fly
+
+ Usage:
+ nick[/switches] <string> [= [replacement_string]]
+ nick[/switches] <template> = <replacement_template>
+ nick/delete <string> or number
+ nicks
+
+ Switches:
+ inputline - replace on the inputline (default)
+ object - replace on object-lookup
+ account - replace on account-lookup
+ list - show all defined aliases (also "nicks" works)
+ delete - remove nick by index in /list
+ clearall - clear all nicks
+
+ Examples:
+ nick hi = say Hello, I'm Sarah!
+ nick/object tom = the tall man
+ nick build $1 $2 = create/drop $1;$2
+ nick tell $1 $2=page $1=$2
+ nick tm?$1=page tallman=$1
+ nick tm\=$1=page tallman=$1
+
+ A 'nick' is a personal string replacement. Use $1, $2, ... to catch arguments.
+ Put the last $-marker without an ending space to catch all remaining text. You
+ can also use unix-glob matching for the left-hand side <string>:
+
+ * - matches everything
+ ? - matches 0 or 1 single characters
+ [abcd] - matches these chars in any order
+ [!abcd] - matches everything not among these chars
+ \= - escape literal '=' you want in your <string>
+
+ Note that no objects are actually renamed or changed by this command - your nicks
+ are only available to you. If you want to permanently add keywords to an object
+ for everyone to use, you need build privileges and the alias command.
+
+ """
+
+ key="nick"
+ switch_options=("inputline","object","account","list","delete","clearall")
+ aliases=["nickname","nicks"]
+ locks="cmd:all()"
+
+
[docs]classCmdGet(COMMAND_DEFAULT_CLASS):
+ """
+ pick up something
+
+ Usage:
+ get <obj>
+
+ Picks up an object from your location and puts it in
+ your inventory.
+ """
+
+ key="get"
+ aliases="grab"
+ locks="cmd:all()"
+ arg_regex=r"\s|$"
+
+
[docs]classCmdDrop(COMMAND_DEFAULT_CLASS):
+ """
+ drop something
+
+ Usage:
+ drop <obj>
+
+ Lets you drop an object from your inventory into the
+ location you are currently in.
+ """
+
+ key="drop"
+ locks="cmd:all()"
+ arg_regex=r"\s|$"
+
+
[docs]deffunc(self):
+ """Implement command"""
+
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("Drop what?")
+ return
+
+ # Because the DROP command by definition looks for items
+ # in inventory, call the search function using location = caller
+ obj=caller.search(
+ self.args,
+ location=caller,
+ nofound_string="You aren't carrying %s."%self.args,
+ multimatch_string="You carry more than one %s:"%self.args,
+ )
+ ifnotobj:
+ return
+
+ # Call the object script's at_before_drop() method.
+ ifnotobj.at_before_drop(caller):
+ return
+
+ success=obj.move_to(caller.location,quiet=True)
+ ifnotsuccess:
+ caller.msg("This couldn't be dropped.")
+ else:
+ caller.msg("You drop %s."%(obj.name,))
+ caller.location.msg_contents("%s drops %s."%(caller.name,obj.name),exclude=caller)
+ # Call the object script's at_drop() method.
+ obj.at_drop(caller)
+
+
+
[docs]classCmdGive(COMMAND_DEFAULT_CLASS):
+ """
+ give away something to someone
+
+ Usage:
+ give <inventory obj> <to||=> <target>
+
+ Gives an items from your inventory to another character,
+ placing it in their inventory.
+ """
+
+ key="give"
+ rhs_split=("="," to ")# Prefer = delimiter, but allow " to " usage.
+ locks="cmd:all()"
+ arg_regex=r"\s|$"
+
+
[docs]deffunc(self):
+ """Implement give"""
+
+ caller=self.caller
+ ifnotself.argsornotself.rhs:
+ caller.msg("Usage: give <inventory object> = <target>")
+ return
+ to_give=caller.search(
+ self.lhs,
+ location=caller,
+ nofound_string="You aren't carrying %s."%self.lhs,
+ multimatch_string="You carry more than one %s:"%self.lhs,
+ )
+ target=caller.search(self.rhs)
+ ifnot(to_giveandtarget):
+ return
+ iftarget==caller:
+ caller.msg("You keep %s to yourself."%to_give.key)
+ return
+ ifnotto_give.location==caller:
+ caller.msg("You are not holding %s."%to_give.key)
+ return
+
+ # calling at_before_give hook method
+ ifnotto_give.at_before_give(caller,target):
+ return
+
+ # give object
+ success=to_give.move_to(target,quiet=True)
+ ifnotsuccess:
+ caller.msg("This could not be given.")
+ else:
+ caller.msg("You give %s to %s."%(to_give.key,target.key))
+ target.msg("%s gives you %s."%(caller.key,to_give.key))
+ # Call the object script's at_give() method.
+ to_give.at_give(caller,target)
+
+
+
[docs]classCmdSetDesc(COMMAND_DEFAULT_CLASS):
+ """
+ describe yourself
+
+ Usage:
+ setdesc <description>
+
+ Add a description to yourself. This
+ will be visible to people when they
+ look at you.
+ """
+
+ key="setdesc"
+ locks="cmd:all()"
+ arg_regex=r"\s|$"
+
+
[docs]deffunc(self):
+ """add the description"""
+
+ ifnotself.args:
+ self.caller.msg("You must add a description.")
+ return
+
+ self.caller.db.desc=self.args.strip()
+ self.caller.msg("You set your description.")
+
+
+
[docs]classCmdSay(COMMAND_DEFAULT_CLASS):
+ """
+ speak as your character
+
+ Usage:
+ say <message>
+
+ Talk to those in your current location.
+ """
+
+ key="say"
+ aliases=['"',"'"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Run the say command"""
+
+ caller=self.caller
+
+ ifnotself.args:
+ caller.msg("Say what?")
+ return
+
+ speech=self.args
+
+ # Calling the at_before_say hook on the character
+ speech=caller.at_before_say(speech)
+
+ # If speech is empty, stop here
+ ifnotspeech:
+ return
+
+ # Call the at_after_say hook on the character
+ caller.at_say(speech,msg_self=True)
+
+
+
[docs]classCmdWhisper(COMMAND_DEFAULT_CLASS):
+ """
+ Speak privately as your character to another
+
+ Usage:
+ whisper <character> = <message>
+ whisper <char1>, <char2> = <message>
+
+ Talk privately to one or more characters in your current location, without
+ others in the room being informed.
+ """
+
+ key="whisper"
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Run the whisper command"""
+
+ caller=self.caller
+
+ ifnotself.lhsornotself.rhs:
+ caller.msg("Usage: whisper <character> = <message>")
+ return
+
+ receivers=[recv.strip()forrecvinself.lhs.split(",")]
+
+ receivers=[caller.search(receiver)forreceiverinset(receivers)]
+ receivers=[recvforrecvinreceiversifrecv]
+
+ speech=self.rhs
+ # If the speech is empty, abort the command
+ ifnotspeechornotreceivers:
+ return
+
+ # Call a hook to change the speech before whispering
+ speech=caller.at_before_say(speech,whisper=True,receivers=receivers)
+
+ # no need for self-message if we are whispering to ourselves (for some reason)
+ msg_self=NoneifcallerinreceiverselseTrue
+ caller.at_say(speech,msg_self=msg_self,receivers=receivers,whisper=True)
+
+
+
[docs]classCmdPose(COMMAND_DEFAULT_CLASS):
+ """
+ strike a pose
+
+ Usage:
+ pose <pose text>
+ pose's <pose text>
+
+ Example:
+ pose is standing by the wall, smiling.
+ -> others will see:
+ Tom is standing by the wall, smiling.
+
+ Describe an action being taken. The pose text will
+ automatically begin with your name.
+ """
+
+ key="pose"
+ aliases=[":","emote"]
+ locks="cmd:all()"
+
+
[docs]defparse(self):
+ """
+ Custom parse the cases where the emote
+ starts with some special letter, such
+ as 's, at which we don't want to separate
+ the caller's name and the emote with a
+ space.
+ """
+ args=self.args
+ ifargsandnotargs[0]in["'",",",":"]:
+ args=" %s"%args.strip()
+ self.args=args
+
+
[docs]deffunc(self):
+ """Hook function"""
+ ifnotself.args:
+ msg="What do you want to do?"
+ self.caller.msg(msg)
+ else:
+ msg="%s%s"%(self.caller.name,self.args)
+ self.caller.location.msg_contents(text=(msg,{"type":"pose"}),from_obj=self.caller)
+
+
+
[docs]classCmdAccess(COMMAND_DEFAULT_CLASS):
+ """
+ show your current game access
+
+ Usage:
+ access
+
+ This command shows you the permission hierarchy and
+ which permission groups you are a member of.
+ """
+
+ key="access"
+ aliases=["groups","hierarchy"]
+ locks="cmd:all()"
+ arg_regex=r"$"
+
+
+"""
+The help command. The basic idea is that help texts for commands
+are best written by those that write the commands - the admins. So
+command-help is all auto-loaded and searched from the current command
+set. The normal, database-tied help system is used for collaborative
+creation of other help topics such as RP help or game-world aides.
+"""
+
+fromdjango.confimportsettings
+fromcollectionsimportdefaultdict
+fromevennia.utils.utilsimportfill,dedent
+fromevennia.commands.commandimportCommand
+fromevennia.help.modelsimportHelpEntry
+fromevennia.utilsimportcreate,evmore
+fromevennia.utils.eveditorimportEvEditor
+fromevennia.utils.utilsimportstring_suggestions,class_from_module
+
+COMMAND_DEFAULT_CLASS=class_from_module(settings.COMMAND_DEFAULT_CLASS)
+HELP_MORE=settings.HELP_MORE
+CMD_IGNORE_PREFIXES=settings.CMD_IGNORE_PREFIXES
+
+# limit symbol import for API
+__all__=("CmdHelp","CmdSetHelp")
+_DEFAULT_WIDTH=settings.CLIENT_DEFAULT_WIDTH
+_SEP="|C"+"-"*_DEFAULT_WIDTH+"|n"
+
+
+
[docs]classCmdHelp(COMMAND_DEFAULT_CLASS):
+ """
+ View help or a list of topics
+
+ Usage:
+ help <topic or command>
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+
+ key="help"
+ aliases=["?"]
+ locks="cmd:all()"
+ arg_regex=r"\s|$"
+
+ # this is a special cmdhandler flag that makes the cmdhandler also pack
+ # the current cmdset with the call to self.func().
+ return_cmdset=True
+
+ # Help messages are wrapped in an EvMore call (unless using the webclient
+ # with separate help popups) If you want to avoid this, simply add
+ # 'HELP_MORE = False' in your settings/conf/settings.py
+ help_more=HELP_MORE
+
+ # suggestion cutoff, between 0 and 1 (1 => perfect match)
+ suggestion_cutoff=0.6
+
+ # number of suggestions (set to 0 to remove suggestions from help)
+ suggestion_maxnum=5
+
+
[docs]defmsg_help(self,text):
+ """
+ messages text to the caller, adding an extra oob argument to indicate
+ that this is a help command result and could be rendered in a separate
+ help window
+ """
+ iftype(self).help_more:
+ usemore=True
+
+ ifself.sessionandself.session.protocol_keyin("websocket","ajax/comet"):
+ try:
+ options=self.account.db._saved_webclient_options
+ ifoptionsandoptions["helppopup"]:
+ usemore=False
+ exceptKeyError:
+ pass
+
+ ifusemore:
+ evmore.msg(self.caller,text,session=self.session)
+ return
+
+ self.msg(text=(text,{"type":"help"}))
+
+
[docs]@staticmethod
+ defformat_help_entry(title,help_text,aliases=None,suggested=None):
+ """
+ This visually formats the help entry.
+ This method can be overriden to customize the way a help
+ entry is displayed.
+
+ Args:
+ title (str): the title of the help entry.
+ help_text (str): the text of the help entry.
+ aliases (list of str or None): the list of aliases.
+ suggested (list of str or None): suggested reading.
+
+ Returns the formatted string, ready to be sent.
+
+ """
+ string=_SEP+"\n"
+ iftitle:
+ string+="|CHelp for |w%s|n"%title
+ ifaliases:
+ string+=" |C(aliases: %s|C)|n"%("|C,|n ".join("|w%s|n"%aliforaliinaliases))
+ ifhelp_text:
+ string+="\n%s"%dedent(help_text.rstrip())
+ ifsuggested:
+ string+="\n\n|CSuggested:|n "
+ string+="%s"%fill("|C,|n ".join("|w%s|n"%sugforsuginsuggested))
+ string.strip()
+ string+="\n"+_SEP
+ returnstring
+
+
[docs]@staticmethod
+ defformat_help_list(hdict_cmds,hdict_db):
+ """
+ Output a category-ordered list. The input are the
+ pre-loaded help files for commands and database-helpfiles
+ respectively. You can override this method to return a
+ custom display of the list of commands and topics.
+ """
+ string=""
+ ifhdict_cmdsandany(hdict_cmds.values()):
+ string+="\n"+_SEP+"\n |CCommand help entries|n\n"+_SEP
+ forcategoryinsorted(hdict_cmds.keys()):
+ string+="\n |w%s|n:\n"%(str(category).title())
+ string+="|G"+fill("|C, |G".join(sorted(hdict_cmds[category])))+"|n"
+ ifhdict_dbandany(hdict_db.values()):
+ string+="\n\n"+_SEP+"\n\r |COther help entries|n\n"+_SEP
+ forcategoryinsorted(hdict_db.keys()):
+ string+="\n\r |w%s|n:\n"%(str(category).title())
+ string+=(
+ "|G"
+ +fill(", ".join(sorted([str(topic)fortopicinhdict_db[category]])))
+ +"|n"
+ )
+ returnstring
+
+
[docs]defcheck_show_help(self,cmd,caller):
+ """
+ Helper method. If this return True, the given cmd
+ auto-help will be viewable in the help listing.
+ Override this to easily select what is shown to
+ the account. Note that only commands available
+ in the caller's merged cmdset are available.
+
+ Args:
+ cmd (Command): Command class from the merged cmdset
+ caller (Character, Account or Session): The current caller
+ executing the help command.
+
+ """
+ # return only those with auto_help set and passing the cmd: lock
+ returncmd.auto_helpandcmd.access(caller)
+
+
[docs]defshould_list_cmd(self,cmd,caller):
+ """
+ Should the specified command appear in the help table?
+
+ This method only checks whether a specified command should
+ appear in the table of topics/commands. The command can be
+ used by the caller (see the 'check_show_help' method) and
+ the command will still be available, for instance, if a
+ character type 'help name of the command'. However, if
+ you return False, the specified command will not appear in
+ the table. This is sometimes useful to "hide" commands in
+ the table, but still access them through the help system.
+
+ Args:
+ cmd: the command to be tested.
+ caller: the caller of the help system.
+
+ Return:
+ True: the command should appear in the table.
+ False: the command shouldn't appear in the table.
+
+ """
+ returncmd.access(caller,"view",default=True)
+
+
[docs]defparse(self):
+ """
+ input is a string containing the command or topic to match.
+ """
+ self.original_args=self.args.strip()
+ self.args=self.args.strip().lower()
+
+
[docs]deffunc(self):
+ """
+ Run the dynamic help entry creator.
+ """
+ query,cmdset=self.args,self.cmdset
+ caller=self.caller
+
+ suggestion_cutoff=self.suggestion_cutoff
+ suggestion_maxnum=self.suggestion_maxnum
+
+ ifnotquery:
+ query="all"
+
+ # removing doublets in cmdset, caused by cmdhandler
+ # having to allow doublet commands to manage exits etc.
+ cmdset.make_unique(caller)
+
+ # retrieve all available commands and database topics
+ all_cmds=[cmdforcmdincmdsetifself.check_show_help(cmd,caller)]
+ all_topics=[
+ topicfortopicinHelpEntry.objects.all()iftopic.access(caller,"view",default=True)
+ ]
+ all_categories=list(
+ set(
+ [cmd.help_category.lower()forcmdinall_cmds]
+ +[topic.help_category.lower()fortopicinall_topics]
+ )
+ )
+
+ ifqueryin("list","all"):
+ # we want to list all available help entries, grouped by category
+ hdict_cmd=defaultdict(list)
+ hdict_topic=defaultdict(list)
+ # create the dictionaries {category:[topic, topic ...]} required by format_help_list
+ # Filter commands that should be reached by the help
+ # system, but not be displayed in the table, or be displayed differently.
+ forcmdinall_cmds:
+ ifself.should_list_cmd(cmd,caller):
+ key=(
+ cmd.auto_help_display_key
+ ifhasattr(cmd,"auto_help_display_key")
+ elsecmd.key
+ )
+ hdict_cmd[cmd.help_category].append(key)
+ [hdict_topic[topic.help_category].append(topic.key)fortopicinall_topics]
+ # report back
+ self.msg_help(self.format_help_list(hdict_cmd,hdict_topic))
+ return
+
+ # Try to access a particular command
+
+ # build vocabulary of suggestions and rate them by string similarity.
+ suggestions=None
+ ifsuggestion_maxnum>0:
+ vocabulary=(
+ [cmd.keyforcmdinall_cmdsifcmd]
+ +[topic.keyfortopicinall_topics]
+ +all_categories
+ )
+ [vocabulary.extend(cmd.aliases)forcmdinall_cmds]
+ suggestions=[
+ sugg
+ forsugginstring_suggestions(
+ query,set(vocabulary),cutoff=suggestion_cutoff,maxnum=suggestion_maxnum
+ )
+ ifsugg!=query
+ ]
+ ifnotsuggestions:
+ suggestions=[
+ suggforsugginvocabularyifsugg!=queryandsugg.startswith(query)
+ ]
+
+ # try an exact command auto-help match
+ match=[cmdforcmdinall_cmdsifcmd==query]
+
+ ifnotmatch:
+ # try an inexact match with prefixes stripped from query and cmds
+ _query=query[1:]ifquery[0]inCMD_IGNORE_PREFIXESelsequery
+
+ match=[
+ cmd
+ forcmdinall_cmds
+ formincmd._matchset
+ ifm==_queryorm[0]inCMD_IGNORE_PREFIXESandm[1:]==_query
+ ]
+
+ iflen(match)==1:
+ cmd=match[0]
+ key=cmd.auto_help_display_keyifhasattr(cmd,"auto_help_display_key")elsecmd.key
+ formatted=self.format_help_entry(
+ key,cmd.get_help(caller,cmdset),aliases=cmd.aliases,suggested=suggestions,
+ )
+ self.msg_help(formatted)
+ return
+
+ # try an exact database help entry match
+ match=list(HelpEntry.objects.find_topicmatch(query,exact=True))
+ iflen(match)==1:
+ formatted=self.format_help_entry(
+ match[0].key,
+ match[0].entrytext,
+ aliases=match[0].aliases.all(),
+ suggested=suggestions,
+ )
+ self.msg_help(formatted)
+ return
+
+ # try to see if a category name was entered
+ ifqueryinall_categories:
+ self.msg_help(
+ self.format_help_list(
+ {
+ query:[
+ cmd.auto_help_display_key
+ ifhasattr(cmd,"auto_help_display_key")
+ elsecmd.key
+ forcmdinall_cmds
+ ifcmd.help_category==query
+ ]
+ },
+ {query:[topic.keyfortopicinall_topicsiftopic.help_category==query]},
+ )
+ )
+ return
+
+ # no exact matches found. Just give suggestions.
+ self.msg(
+ self.format_help_entry(
+ "",f"No help entry found for '{query}'",None,suggested=suggestions
+ ),
+ options={"type":"help"},
+ )
[docs]classCmdSetHelp(COMMAND_DEFAULT_CLASS):
+ """
+ Edit the help database.
+
+ Usage:
+ help[/switches] <topic>[[;alias;alias][,category[,locks]] [= <text>]
+
+ Switches:
+ edit - open a line editor to edit the topic's help text.
+ replace - overwrite existing help topic.
+ append - add text to the end of existing topic with a newline between.
+ extend - as append, but don't add a newline.
+ delete - remove help topic.
+
+ Examples:
+ sethelp throw = This throws something at ...
+ sethelp/append pickpocketing,Thievery = This steals ...
+ sethelp/replace pickpocketing, ,attr(is_thief) = This steals ...
+ sethelp/edit thievery
+
+ This command manipulates the help database. A help entry can be created,
+ appended/merged to and deleted. If you don't assign a category, the
+ "General" category will be used. If no lockstring is specified, default
+ is to let everyone read the help file.
+
+ """
+
+ key="sethelp"
+ switch_options=("edit","replace","append","extend","delete")
+ locks="cmd:perm(Helper)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ """Implement the function"""
+
+ switches=self.switches
+ lhslist=self.lhslist
+
+ ifnotself.args:
+ self.msg(
+ "Usage: sethelp[/switches] <topic>[;alias;alias][,category[,locks,..] = <text>"
+ )
+ return
+
+ nlist=len(lhslist)
+ topicstr=lhslist[0]ifnlist>0else""
+ ifnottopicstr:
+ self.msg("You have to define a topic!")
+ return
+ topicstrlist=topicstr.split(";")
+ topicstr,aliases=(topicstrlist[0],topicstrlist[1:]iflen(topicstr)>1else[])
+ aliastxt=("(aliases: %s)"%", ".join(aliases))ifaliaseselse""
+ old_entry=None
+
+ # check if we have an old entry with the same name
+ try:
+ forquerystrintopicstrlist:
+ old_entry=HelpEntry.objects.find_topicmatch(querystr)# also search by alias
+ ifold_entry:
+ old_entry=list(old_entry)[0]
+ break
+ category=lhslist[1]ifnlist>1elseold_entry.help_category
+ lockstring=",".join(lhslist[2:])ifnlist>2elseold_entry.locks.get()
+ exceptException:
+ old_entry=None
+ category=lhslist[1]ifnlist>1else"General"
+ lockstring=",".join(lhslist[2:])ifnlist>2else"view:all()"
+ category=category.lower()
+
+ if"edit"inswitches:
+ # open the line editor to edit the helptext. No = is needed.
+ ifold_entry:
+ topicstr=old_entry.key
+ ifself.rhs:
+ # we assume append here.
+ old_entry.entrytext+="\n%s"%self.rhs
+ helpentry=old_entry
+ else:
+ helpentry=create.create_help_entry(
+ topicstr,self.rhs,category=category,locks=lockstring,aliases=aliases
+ )
+ self.caller.db._editing_help=helpentry
+
+ EvEditor(
+ self.caller,
+ loadfunc=_loadhelp,
+ savefunc=_savehelp,
+ quitfunc=_quithelp,
+ key="topic {}".format(topicstr),
+ persistent=True,
+ )
+ return
+
+ if"append"inswitchesor"merge"inswitchesor"extend"inswitches:
+ # merge/append operations
+ ifnotold_entry:
+ self.msg("Could not find topic '%s'. You must give an exact name."%topicstr)
+ return
+ ifnotself.rhs:
+ self.msg("You must supply text to append/merge.")
+ return
+ if"merge"inswitches:
+ old_entry.entrytext+=" "+self.rhs
+ else:
+ old_entry.entrytext+="\n%s"%self.rhs
+ old_entry.aliases.add(aliases)
+ self.msg("Entry updated:\n%s%s"%(old_entry.entrytext,aliastxt))
+ return
+ if"delete"inswitchesor"del"inswitches:
+ # delete the help entry
+ ifnotold_entry:
+ self.msg("Could not find topic '%s'%s."%(topicstr,aliastxt))
+ return
+ old_entry.delete()
+ self.msg("Deleted help entry '%s'%s."%(topicstr,aliastxt))
+ return
+
+ # at this point it means we want to add a new help entry.
+ ifnotself.rhs:
+ self.msg("You must supply a help text to add.")
+ return
+ ifold_entry:
+ if"replace"inswitches:
+ # overwrite old entry
+ old_entry.key=topicstr
+ old_entry.entrytext=self.rhs
+ old_entry.help_category=category
+ old_entry.locks.clear()
+ old_entry.locks.add(lockstring)
+ old_entry.aliases.add(aliases)
+ old_entry.save()
+ self.msg("Overwrote the old topic '%s'%s."%(topicstr,aliastxt))
+ else:
+ self.msg(
+ "Topic '%s'%s already exists. Use /replace to overwrite "
+ "or /append or /merge to add text to it."%(topicstr,aliastxt)
+ )
+ else:
+ # no old entry. Create a new one.
+ new_entry=create.create_help_entry(
+ topicstr,self.rhs,category=category,locks=lockstring,aliases=aliases
+ )
+ ifnew_entry:
+ self.msg("Topic '%s'%s was successfully created."%(topicstr,aliastxt))
+ if"edit"inswitches:
+ # open the line editor to edit the helptext
+ self.caller.db._editing_help=new_entry
+ EvEditor(
+ self.caller,
+ loadfunc=_loadhelp,
+ savefunc=_savehelp,
+ quitfunc=_quithelp,
+ key="topic {}".format(new_entry.key),
+ persistent=True,
+ )
+ return
+ else:
+ self.msg(
+ "Error when creating topic '%s'%s! Contact an admin."%(topicstr,aliastxt)
+ )
Source code for evennia.commands.default.muxcommand
+"""
+The command template for the default MUX-style command set. There
+is also an Account/OOC version that makes sure caller is an Account object.
+"""
+
+fromevennia.utilsimportutils
+fromevennia.commands.commandimportCommand
+
+# limit symbol import for API
+__all__=("MuxCommand","MuxAccountCommand")
+
+
+
[docs]classMuxCommand(Command):
+ """
+ This sets up the basis for a MUX command. The idea
+ is that most other Mux-related commands should just
+ inherit from this and don't have to implement much
+ parsing of their own unless they do something particularly
+ advanced.
+
+ Note that the class's __doc__ string (this text) is
+ used by Evennia to create the automatic help entry for
+ the command, so make sure to document consistently here.
+ """
+
+
[docs]defhas_perm(self,srcobj):
+ """
+ This is called by the cmdhandler to determine
+ if srcobj is allowed to execute this command.
+ We just show it here for completeness - we
+ are satisfied using the default check in Command.
+ """
+ returnsuper().has_perm(srcobj)
+
+
[docs]defat_pre_cmd(self):
+ """
+ This hook is called before self.parse() on all commands
+ """
+ pass
+
+
[docs]defat_post_cmd(self):
+ """
+ This hook is called after the command has finished executing
+ (after self.func()).
+ """
+ pass
+
+
[docs]defparse(self):
+ """
+ This method is called by the cmdhandler once the command name
+ has been identified. It creates a new set of member variables
+ that can be later accessed from self.func() (see below)
+
+ The following variables are available for our use when entering this
+ method (from the command definition, and assigned on the fly by the
+ cmdhandler):
+ self.key - the name of this command ('look')
+ self.aliases - the aliases of this cmd ('l')
+ self.permissions - permission string for this command
+ self.help_category - overall category of command
+
+ self.caller - the object calling this command
+ self.cmdstring - the actual command name used to call this
+ (this allows you to know which alias was used,
+ for example)
+ self.args - the raw input; everything following self.cmdstring.
+ self.cmdset - the cmdset from which this command was picked. Not
+ often used (useful for commands like 'help' or to
+ list all available commands etc)
+ self.obj - the object on which this command was defined. It is often
+ the same as self.caller.
+
+ A MUX command has the following possible syntax:
+
+ name[ with several words][/switch[/switch..]] arg1[,arg2,...] [[=|,] arg[,..]]
+
+ The 'name[ with several words]' part is already dealt with by the
+ cmdhandler at this point, and stored in self.cmdname (we don't use
+ it here). The rest of the command is stored in self.args, which can
+ start with the switch indicator /.
+
+ Optional variables to aid in parsing, if set:
+ self.switch_options - (tuple of valid /switches expected by this
+ command (without the /))
+ self.rhs_split - Alternate string delimiter or tuple of strings
+ to separate left/right hand sides. tuple form
+ gives priority split to first string delimiter.
+
+ This parser breaks self.args into its constituents and stores them in the
+ following variables:
+ self.switches = [list of /switches (without the /)]
+ self.raw = This is the raw argument input, including switches
+ self.args = This is re-defined to be everything *except* the switches
+ self.lhs = Everything to the left of = (lhs:'left-hand side'). If
+ no = is found, this is identical to self.args.
+ self.rhs: Everything to the right of = (rhs:'right-hand side').
+ If no '=' is found, this is None.
+ self.lhslist - [self.lhs split into a list by comma]
+ self.rhslist - [list of self.rhs split into a list by comma]
+ self.arglist = [list of space-separated args (stripped, including '=' if it exists)]
+
+ All args and list members are stripped of excess whitespace around the
+ strings, but case is preserved.
+ """
+ raw=self.args
+ args=raw.strip()
+ # Without explicitly setting these attributes, they assume default values:
+ ifnothasattr(self,"switch_options"):
+ self.switch_options=None
+ ifnothasattr(self,"rhs_split"):
+ self.rhs_split="="
+ ifnothasattr(self,"account_caller"):
+ self.account_caller=False
+
+ # split out switches
+ switches,delimiters=[],self.rhs_split
+ ifself.switch_options:
+ self.switch_options=[opt.lower()foroptinself.switch_options]
+ ifargsandlen(args)>1andraw[0]=="/":
+ # we have a switch, or a set of switches. These end with a space.
+ switches=args[1:].split(None,1)
+ iflen(switches)>1:
+ switches,args=switches
+ switches=switches.split("/")
+ else:
+ args=""
+ switches=switches[0].split("/")
+ # If user-provides switches, parse them with parser switch options.
+ ifswitchesandself.switch_options:
+ valid_switches,unused_switches,extra_switches=[],[],[]
+ forelementinswitches:
+ option_check=[optforoptinself.switch_optionsifopt==element]
+ ifnotoption_check:
+ option_check=[
+ optforoptinself.switch_optionsifopt.startswith(element)
+ ]
+ match_count=len(option_check)
+ ifmatch_count>1:
+ extra_switches.extend(
+ option_check
+ )# Either the option provided is ambiguous,
+ elifmatch_count==1:
+ valid_switches.extend(option_check)# or it is a valid option abbreviation,
+ elifmatch_count==0:
+ unused_switches.append(element)# or an extraneous option to be ignored.
+ ifextra_switches:# User provided switches
+ self.msg(
+ "|g%s|n: |wAmbiguous switch supplied: Did you mean /|C%s|w?"
+ %(self.cmdstring," |nor /|C".join(extra_switches))
+ )
+ ifunused_switches:
+ plural=""iflen(unused_switches)==1else"es"
+ self.msg(
+ '|g%s|n: |wExtra switch%s "/|C%s|w" ignored.'
+ %(self.cmdstring,plural,"|n, /|C".join(unused_switches))
+ )
+ switches=valid_switches# Only include valid_switches in command function call
+ arglist=[arg.strip()forarginargs.split()]
+
+ # check for arg1, arg2, ... = argA, argB, ... constructs
+ lhs,rhs=args.strip(),None
+ iflhs:
+ ifdelimitersandhasattr(delimiters,"__iter__"):# If delimiter is iterable,
+ best_split=delimiters[0]# (default to first delimiter)
+ forthis_splitindelimiters:# try each delimiter
+ ifthis_splitinlhs:# to find first successful split
+ best_split=this_split# to be the best split.
+ break
+ else:
+ best_split=delimiters
+ # Parse to separate left into left/right sides using best_split delimiter string
+ ifbest_splitinlhs:
+ lhs,rhs=lhs.split(best_split,1)
+ # Trim user-injected whitespace
+ rhs=rhs.strip()ifrhsisnotNoneelseNone
+ lhs=lhs.strip()
+ # Further split left/right sides by comma delimiter
+ lhslist=[arg.strip()forarginlhs.split(",")]iflhsisnotNoneelse""
+ rhslist=[arg.strip()forarginrhs.split(",")]ifrhsisnotNoneelse""
+ # save to object properties:
+ self.raw=raw
+ self.switches=switches
+ self.args=args.strip()
+ self.arglist=arglist
+ self.lhs=lhs
+ self.lhslist=lhslist
+ self.rhs=rhs
+ self.rhslist=rhslist
+
+ # if the class has the account_caller property set on itself, we make
+ # sure that self.caller is always the account if possible. We also create
+ # a special property "character" for the puppeted object, if any. This
+ # is convenient for commands defined on the Account only.
+ ifself.account_caller:
+ ifutils.inherits_from(self.caller,"evennia.objects.objects.DefaultObject"):
+ # caller is an Object/Character
+ self.character=self.caller
+ self.caller=self.caller.account
+ elifutils.inherits_from(self.caller,"evennia.accounts.accounts.DefaultAccount"):
+ # caller was already an Account
+ self.character=self.caller.get_puppet(self.session)
+ else:
+ self.character=None
+
+
[docs]defget_command_info(self):
+ """
+ Update of parent class's get_command_info() for MuxCommand.
+ """
+ variables="\n".join(
+ " |w{}|n ({}): {}".format(key,type(val),val)forkey,valinself.__dict__.items()
+ )
+ string=f"""
+Command {self} has no defined `func()` - showing on-command variables: No child func() defined for {self} - available variables:
+{variables}
+ """
+ self.caller.msg(string)
+ # a simple test command to show the available properties
+ string="-"*50
+ string+="\n|w%s|n - Command variables from evennia:\n"%self.key
+ string+="-"*50
+ string+="\nname of cmd (self.key): |w%s|n\n"%self.key
+ string+="cmd aliases (self.aliases): |w%s|n\n"%self.aliases
+ string+="cmd locks (self.locks): |w%s|n\n"%self.locks
+ string+="help category (self.help_category): |w%s|n\n"%self.help_category
+ string+="object calling (self.caller): |w%s|n\n"%self.caller
+ string+="object storing cmdset (self.obj): |w%s|n\n"%self.obj
+ string+="command string given (self.cmdstring): |w%s|n\n"%self.cmdstring
+ # show cmdset.key instead of cmdset to shorten output
+ string+=utils.fill("current cmdset (self.cmdset): |w%s|n\n"%self.cmdset)
+ string+="\n"+"-"*50
+ string+="\nVariables from MuxCommand baseclass\n"
+ string+="-"*50
+ string+="\nraw argument (self.raw): |w%s|n \n"%self.raw
+ string+="cmd args (self.args): |w%s|n\n"%self.args
+ string+="cmd switches (self.switches): |w%s|n\n"%self.switches
+ string+="cmd options (self.switch_options): |w%s|n\n"%self.switch_options
+ string+="cmd parse left/right using (self.rhs_split): |w%s|n\n"%self.rhs_split
+ string+="space-separated arg list (self.arglist): |w%s|n\n"%self.arglist
+ string+="lhs, left-hand side of '=' (self.lhs): |w%s|n\n"%self.lhs
+ string+="lhs, comma separated (self.lhslist): |w%s|n\n"%self.lhslist
+ string+="rhs, right-hand side of '=' (self.rhs): |w%s|n\n"%self.rhs
+ string+="rhs, comma separated (self.rhslist): |w%s|n\n"%self.rhslist
+ string+="-"*50
+ self.caller.msg(string)
+
+
[docs]deffunc(self):
+ """
+ This is the hook function that actually does all the work. It is called
+ by the cmdhandler right after self.parser() finishes, and so has access
+ to all the variables defined therein.
+ """
+ self.get_command_info()
+
+
+
[docs]classMuxAccountCommand(MuxCommand):
+ """
+ This is an on-Account version of the MuxCommand. Since these commands sit
+ on Accounts rather than on Characters/Objects, we need to check
+ this in the parser.
+
+ Account commands are available also when puppeting a Character, it's
+ just that they are applied with a lower priority and are always
+ available, also when disconnected from a character (i.e. "ooc").
+
+ This class makes sure that caller is always an Account object, while
+ creating a new property "character" that is set only if a
+ character is actually attached to this Account and Session.
+ """
+
+ account_caller=True# Using MuxAccountCommand explicitly defaults the caller to an account
Source code for evennia.commands.default.syscommands
+"""
+System commands
+
+These are the default commands called by the system commandhandler
+when various exceptions occur. If one of these commands are not
+implemented and part of the current cmdset, the engine falls back
+to a default solution instead.
+
+Some system commands are shown in this module
+as a REFERENCE only (they are not all added to Evennia's
+default cmdset since they don't currently do anything differently from the
+default backup systems hard-wired in the engine).
+
+Overloading these commands in a cmdset can be used to create
+interesting effects. An example is using the NoMatch system command
+to implement a line-editor where you don't have to start each
+line with a command (if there is no match to a known command,
+the line is just added to the editor buffer).
+"""
+
+fromevennia.comms.modelsimportChannelDB
+fromevennia.utilsimportcreate
+fromevennia.utils.utilsimportat_search_result
+
+# The command keys the engine is calling
+# (the actual names all start with __)
+fromevennia.commands.cmdhandlerimportCMD_NOINPUT
+fromevennia.commands.cmdhandlerimportCMD_NOMATCH
+fromevennia.commands.cmdhandlerimportCMD_MULTIMATCH
+fromevennia.commands.cmdhandlerimportCMD_CHANNEL
+fromevennia.utilsimportutils
+
+fromdjango.confimportsettings
+
+COMMAND_DEFAULT_CLASS=utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# Command called when there is no input at line
+# (i.e. an lone return key)
+
+
+
[docs]classSystemNoInput(COMMAND_DEFAULT_CLASS):
+ """
+ This is called when there is no input given
+ """
+
+ key=CMD_NOINPUT
+ locks="cmd:all()"
+
+
+
+
+#
+# Command called when there was no match to the
+# command name
+#
+
[docs]classSystemNoMatch(COMMAND_DEFAULT_CLASS):
+ """
+ No command was found matching the given input.
+ """
+
+ key=CMD_NOMATCH
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """
+ This is given the failed raw string as input.
+ """
+ self.msg("Huh?")
+
+
+#
+# Command called when there were multiple matches to the command.
+#
+
[docs]classSystemMultimatch(COMMAND_DEFAULT_CLASS):
+ """
+ Multiple command matches.
+
+ The cmdhandler adds a special attribute 'matches' to this
+ system command.
+
+ matches = [(cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname) , (cmdname, ...), ...]
+
+ Here, `cmdname` is the command's name and `args` the rest of the incoming string,
+ without said command name. `cmdobj` is the Command instance, the cmdlen is
+ the same as len(cmdname) and mratio is a measure of how big a part of the
+ full input string the cmdname takes up - an exact match would be 1.0. Finally,
+ the `raw_cmdname` is the cmdname unmodified by eventual prefix-stripping.
+
+ """
+
+ key=CMD_MULTIMATCH
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """
+ Handle multiple-matches by using the at_search_result default handler.
+
+ """
+ # this was set by the cmdparser and is a tuple
+ # (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname). See
+ # evennia.commands.cmdparse.create_match for more details.
+ matches=self.matches
+ # at_search_result will itself msg the multimatch options to the caller.
+ at_search_result([match[2]formatchinmatches],self.caller,query=matches[0][0])
+
+
+# Command called when the command given at the command line
+# was identified as a channel name, like there existing a
+# channel named 'ooc' and the user wrote
+# > ooc Hello!
+
+
+
[docs]classSystemSendToChannel(COMMAND_DEFAULT_CLASS):
+ """
+ This is a special command that the cmdhandler calls
+ when it detects that the command given matches
+ an existing Channel object key (or alias).
+ """
+
+ key=CMD_CHANNEL
+ locks="cmd:all()"
+
+
[docs]classCmdReload(COMMAND_DEFAULT_CLASS):
+ """
+ reload the server
+
+ Usage:
+ reload [reason]
+
+ This restarts the server. The Portal is not
+ affected. Non-persistent scripts will survive a reload (use
+ reset to purge) and at_reload() hooks will be called.
+ """
+
+ key="reload"
+ aliases=["restart"]
+ locks="cmd:perm(reload) or perm(Developer)"
+ help_category="System"
+
+
[docs]classCmdReset(COMMAND_DEFAULT_CLASS):
+ """
+ reset and reboot the server
+
+ Usage:
+ reset
+
+ Notes:
+ For normal updating you are recommended to use reload rather
+ than this command. Use shutdown for a complete stop of
+ everything.
+
+ This emulates a cold reboot of the Server component of Evennia.
+ The difference to shutdown is that the Server will auto-reboot
+ and that it does not affect the Portal, so no users will be
+ disconnected. Contrary to reload however, all shutdown hooks will
+ be called and any non-database saved scripts, ndb-attributes,
+ cmdsets etc will be wiped.
+
+ """
+
+ key="reset"
+ aliases=["reboot"]
+ locks="cmd:perm(reload) or perm(Developer)"
+ help_category="System"
+
+
[docs]deffunc(self):
+ """
+ Reload the system.
+ """
+ SESSIONS.announce_all(" Server resetting/restarting ...")
+ SESSIONS.portal_reset_server()
+
+
+
[docs]classCmdShutdown(COMMAND_DEFAULT_CLASS):
+
+ """
+ stop the server completely
+
+ Usage:
+ shutdown [announcement]
+
+ Gracefully shut down both Server and Portal.
+ """
+
+ key="shutdown"
+ locks="cmd:perm(shutdown) or perm(Developer)"
+ help_category="System"
+
+
[docs]deffunc(self):
+ """Define function"""
+ # Only allow shutdown if caller has session
+ ifnotself.caller.sessions.get():
+ return
+ self.msg("Shutting down server ...")
+ announcement="\nServer is being SHUT DOWN!\n"
+ ifself.args:
+ announcement+="%s\n"%self.args
+ logger.log_info("Server shutdown by %s."%self.caller.name)
+ SESSIONS.announce_all(announcement)
+ SESSIONS.portal_shutdown()
+
+
+def_py_load(caller):
+ return""
+
+
+def_py_code(caller,buf):
+ """
+ Execute the buffer.
+ """
+ measure_time=caller.db._py_measure_time
+ client_raw=caller.db._py_clientraw
+ string="Executing code%s ..."%(" (measure timing)"ifmeasure_timeelse"")
+ caller.msg(string)
+ _run_code_snippet(
+ caller,buf,mode="exec",measure_time=measure_time,client_raw=client_raw,show_input=False
+ )
+ returnTrue
+
+
+def_py_quit(caller):
+ delcaller.db._py_measure_time
+ caller.msg("Exited the code editor.")
+
+
+def_run_code_snippet(
+ caller,pycode,mode="eval",measure_time=False,client_raw=False,show_input=True
+):
+ """
+ Run code and try to display information to the caller.
+
+ Args:
+ caller (Object): The caller.
+ pycode (str): The Python code to run.
+ measure_time (bool, optional): Should we measure the time of execution?
+ client_raw (bool, optional): Should we turn off all client-specific escaping?
+ show_input (bookl, optional): Should we display the input?
+
+ """
+ # Try to retrieve the session
+ session=caller
+ ifhasattr(caller,"sessions"):
+ sessions=caller.sessions.all()
+
+ available_vars=evennia_local_vars(caller)
+
+ ifshow_input:
+ forsessioninsessions:
+ try:
+ caller.msg(">>> %s"%pycode,session=session,options={"raw":True})
+ exceptTypeError:
+ caller.msg(">>> %s"%pycode,options={"raw":True})
+
+ try:
+ # reroute standard output to game client console
+ old_stdout=sys.stdout
+ old_stderr=sys.stderr
+
+ classFakeStd:
+ def__init__(self,caller):
+ self.caller=caller
+
+ defwrite(self,string):
+ self.caller.msg(string.rsplit("\n",1)[0])
+
+ fake_std=FakeStd(caller)
+ sys.stdout=fake_std
+ sys.stderr=fake_std
+
+ try:
+ pycode_compiled=compile(pycode,"",mode)
+ exceptException:
+ mode="exec"
+ pycode_compiled=compile(pycode,"",mode)
+
+ duration=""
+ ifmeasure_time:
+ t0=time.time()
+ ret=eval(pycode_compiled,{},available_vars)
+ t1=time.time()
+ duration=" (runtime ~ %.4f ms)"%((t1-t0)*1000)
+ caller.msg(duration)
+ else:
+ ret=eval(pycode_compiled,{},available_vars)
+
+ exceptException:
+ errlist=traceback.format_exc().split("\n")
+ iflen(errlist)>4:
+ errlist=errlist[4:]
+ ret="\n".join("%s"%lineforlineinerrlistifline)
+ finally:
+ # return to old stdout
+ sys.stdout=old_stdout
+ sys.stderr=old_stderr
+
+ ifretisNone:
+ return
+ elifisinstance(ret,tuple):
+ # we must convert here to allow msg to pass it (a tuple is confused
+ # with a outputfunc structure)
+ ret=str(ret)
+
+ forsessioninsessions:
+ try:
+ caller.msg(ret,session=session,options={"raw":True,"client_raw":client_raw})
+ exceptTypeError:
+ caller.msg(ret,options={"raw":True,"client_raw":client_raw})
+
+
+defevennia_local_vars(caller):
+ """Return Evennia local variables usable in the py command as a dictionary."""
+ importevennia
+
+ return{
+ "self":caller,
+ "me":caller,
+ "here":getattr(caller,"location",None),
+ "evennia":evennia,
+ "ev":evennia,
+ "inherits_from":utils.inherits_from,
+ }
+
+
+classEvenniaPythonConsole(code.InteractiveConsole):
+
+ """Evennia wrapper around a Python interactive console."""
+
+ def__init__(self,caller):
+ super().__init__(evennia_local_vars(caller))
+ self.caller=caller
+
+ defwrite(self,string):
+ """Don't send to stderr, send to self.caller."""
+ self.caller.msg(string)
+
+ defpush(self,line):
+ """Push some code, whether complete or not."""
+ old_stdout=sys.stdout
+ old_stderr=sys.stderr
+
+ classFakeStd:
+ def__init__(self,caller):
+ self.caller=caller
+
+ defwrite(self,string):
+ self.caller.msg(string.split("\n",1)[0])
+
+ fake_std=FakeStd(self.caller)
+ sys.stdout=fake_std
+ sys.stderr=fake_std
+ result=None
+ try:
+ result=super().push(line)
+ finally:
+ sys.stdout=old_stdout
+ sys.stderr=old_stderr
+ returnresult
+
+
+
[docs]classCmdPy(COMMAND_DEFAULT_CLASS):
+ """
+ execute a snippet of python code
+
+ Usage:
+ py [cmd]
+ py/edit
+ py/time <cmd>
+ py/clientraw <cmd>
+ py/noecho
+
+ Switches:
+ time - output an approximate execution time for <cmd>
+ edit - open a code editor for multi-line code experimentation
+ clientraw - turn off all client-specific escaping. Note that this may
+ lead to different output depending on prototocol (such as angular brackets
+ being parsed as HTML in the webclient but not in telnet clients)
+ noecho - in Python console mode, turn off the input echo (e.g. if your client
+ does this for you already)
+
+ Without argument, open a Python console in-game. This is a full console,
+ accepting multi-line Python code for testing and debugging. Type `exit()` to
+ return to the game. If Evennia is reloaded, the console will be closed.
+
+ Enter a line of instruction after the 'py' command to execute it
+ immediately. Separate multiple commands by ';' or open the code editor
+ using the /edit switch (all lines added in editor will be executed
+ immediately when closing or using the execute command in the editor).
+
+ A few variables are made available for convenience in order to offer access
+ to the system (you can import more at execution time).
+
+ Available variables in py environment:
+ self, me : caller
+ here : caller.location
+ evennia : the evennia API
+ inherits_from(obj, parent) : check object inheritance
+
+ You can explore The evennia API from inside the game by calling
+ the `__doc__` property on entities:
+ py evennia.__doc__
+ py evennia.managers.__doc__
+
+ |rNote: In the wrong hands this command is a severe security risk. It
+ should only be accessible by trusted server admins/superusers.|n
+
+ """
+
+ key="py"
+ aliases=["!"]
+ switch_options=("time","edit","clientraw","noecho")
+ locks="cmd:perm(py) or perm(Developer)"
+ help_category="System"
+
+
+
+
+classScriptEvMore(EvMore):
+ """
+ Listing 1000+ Scripts can be very slow and memory-consuming. So
+ we use this custom EvMore child to build en EvTable only for
+ each page of the list.
+
+ """
+
+ definit_pages(self,scripts):
+ """Prepare the script list pagination"""
+ script_pages=Paginator(scripts,max(1,int(self.height/2)))
+ super().init_pages(script_pages)
+
+ defpage_formatter(self,scripts):
+ """Takes a page of scripts and formats the output
+ into an EvTable."""
+
+ ifnotscripts:
+ return"<No scripts>"
+
+ table=EvTable(
+ "|wdbref|n",
+ "|wobj|n",
+ "|wkey|n",
+ "|wintval|n",
+ "|wnext|n",
+ "|wrept|n",
+ "|wdb",
+ "|wtypeclass|n",
+ "|wdesc|n",
+ align="r",
+ border="tablecols",
+ width=self.width,
+ )
+
+ forscriptinscripts:
+
+ nextrep=script.time_until_next_repeat()
+ ifnextrepisNone:
+ nextrep="PAUSED"ifscript.db._paused_timeelse"--"
+ else:
+ nextrep="%ss"%nextrep
+
+ maxrepeat=script.repeats
+ remaining=script.remaining_repeats()or0
+ ifmaxrepeat:
+ rept="%i/%i"%(maxrepeat-remaining,maxrepeat)
+ else:
+ rept="-/-"
+
+ table.add_row(
+ script.id,
+ f"{script.obj.key}({script.obj.dbref})"
+ if(hasattr(script,"obj")andscript.obj)
+ else"<Global>",
+ script.key,
+ script.intervalifscript.interval>0else"--",
+ nextrep,
+ rept,
+ "*"ifscript.persistentelse"-",
+ script.typeclass_path.rsplit(".",1)[-1],
+ crop(script.desc,width=20),
+ )
+
+ returnstr(table)
+
+
+
[docs]classCmdScripts(COMMAND_DEFAULT_CLASS):
+ """
+ list and manage all running scripts
+
+ Usage:
+ scripts[/switches] [#dbref, key, script.path or <obj>]
+
+ Switches:
+ start - start a script (must supply a script path)
+ stop - stops an existing script
+ kill - kills a script - without running its cleanup hooks
+ validate - run a validation on the script(s)
+
+ If no switches are given, this command just views all active
+ scripts. The argument can be either an object, at which point it
+ will be searched for all scripts defined on it, or a script name
+ or #dbref. For using the /stop switch, a unique script #dbref is
+ required since whole classes of scripts often have the same name.
+
+ Use script for managing commands on objects.
+ """
+
+ key="scripts"
+ aliases=["globalscript","listscripts"]
+ switch_options=("start","stop","kill","validate")
+ locks="cmd:perm(listscripts) or perm(Admin)"
+ help_category="System"
+
+ excluded_typeclass_paths=["evennia.prototypes.prototypes.DbPrototype"]
+
+
[docs]deffunc(self):
+ """implement method"""
+
+ caller=self.caller
+ args=self.args
+
+ ifargs:
+ if"start"inself.switches:
+ # global script-start mode
+ new_script=create.create_script(args)
+ ifnew_script:
+ caller.msg("Global script %s was started successfully."%args)
+ else:
+ caller.msg("Global script %s could not start correctly. See logs."%args)
+ return
+
+ # test first if this is a script match
+ scripts=ScriptDB.objects.get_all_scripts(key=args)
+ ifnotscripts:
+ # try to find an object instead.
+ objects=ObjectDB.objects.object_search(args)
+ ifobjects:
+ scripts=[]
+ forobjinobjects:
+ # get all scripts on the object(s)
+ scripts.extend(ScriptDB.objects.get_all_scripts_on_obj(obj))
+ else:
+ # we want all scripts.
+ scripts=ScriptDB.objects.get_all_scripts()
+ ifnotscripts:
+ caller.msg("No scripts are running.")
+ return
+ # filter any found scripts by tag category.
+ scripts=scripts.exclude(db_typeclass_path__in=self.excluded_typeclass_paths)
+
+ ifnotscripts:
+ string="No scripts found with a key '%s', or on an object named '%s'."%(args,args)
+ caller.msg(string)
+ return
+
+ ifself.switchesandself.switches[0]in("stop","del","delete","kill"):
+ # we want to delete something
+ iflen(scripts)==1:
+ # we have a unique match!
+ if"kill"inself.switches:
+ string="Killing script '%s'"%scripts[0].key
+ scripts[0].stop(kill=True)
+ else:
+ string="Stopping script '%s'."%scripts[0].key
+ scripts[0].stop()
+ # import pdb # DEBUG
+ # pdb.set_trace() # DEBUG
+ ScriptDB.objects.validate()# just to be sure all is synced
+ caller.msg(string)
+ else:
+ # multiple matches.
+ ScriptEvMore(caller,scripts,session=self.session)
+ caller.msg("Multiple script matches. Please refine your search")
+ elifself.switchesandself.switches[0]in("validate","valid","val"):
+ # run validation on all found scripts
+ nr_started,nr_stopped=ScriptDB.objects.validate(scripts=scripts)
+ string="Validated %s scripts. "%ScriptDB.objects.all().count()
+ string+="Started %s and stopped %s scripts."%(nr_started,nr_stopped)
+ caller.msg(string)
+ else:
+ # No stopping or validation. We just want to view things.
+ ScriptEvMore(caller,scripts.order_by("id"),session=self.session)
+
+
+
[docs]classCmdObjects(COMMAND_DEFAULT_CLASS):
+ """
+ statistics on objects in the database
+
+ Usage:
+ objects [<nr>]
+
+ Gives statictics on objects in database as well as
+ a list of <nr> latest objects in database. If not
+ given, <nr> defaults to 10.
+ """
+
+ key="objects"
+ aliases=["listobjects","listobjs","stats","db"]
+ locks="cmd:perm(listobjects) or perm(Builder)"
+ help_category="System"
+
+
[docs]classCmdAccounts(COMMAND_DEFAULT_CLASS):
+ """
+ Manage registered accounts
+
+ Usage:
+ accounts [nr]
+ accounts/delete <name or #id> [: reason]
+
+ Switches:
+ delete - delete an account from the server
+
+ By default, lists statistics about the Accounts registered with the game.
+ It will list the <nr> amount of latest registered accounts
+ If not given, <nr> defaults to 10.
+ """
+
+ key="accounts"
+ aliases=["account","listaccounts"]
+ switch_options=("delete",)
+ locks="cmd:perm(listaccounts) or perm(Admin)"
+ help_category="System"
+
+
[docs]deffunc(self):
+ """List the accounts"""
+
+ caller=self.caller
+ args=self.args
+
+ if"delete"inself.switches:
+ account=getattr(caller,"account")
+ ifnotaccountornotaccount.check_permstring("Developer"):
+ caller.msg("You are not allowed to delete accounts.")
+ return
+ ifnotargs:
+ caller.msg("Usage: accounts/delete <name or #id> [: reason]")
+ return
+ reason=""
+ if":"inargs:
+ args,reason=[arg.strip()forarginargs.split(":",1)]
+ # We use account_search since we want to be sure to find also accounts
+ # that lack characters.
+ accounts=search.account_search(args)
+ ifnotaccounts:
+ self.msg("Could not find an account by that name.")
+ return
+ iflen(accounts)>1:
+ string="There were multiple matches:\n"
+ string+="\n".join(" %s%s"%(account.id,account.key)foraccountinaccounts)
+ self.msg(string)
+ return
+ account=accounts.first()
+ ifnotaccount.access(caller,"delete"):
+ self.msg("You don't have the permissions to delete that account.")
+ return
+ username=account.username
+ # ask for confirmation
+ confirm=(
+ "It is often better to block access to an account rather than to delete it. "
+ "|yAre you sure you want to permanently delete "
+ "account '|n{}|y'|n yes/[no]?".format(username)
+ )
+ answer=yield(confirm)
+ ifanswer.lower()notin("y","yes"):
+ caller.msg("Canceled deletion.")
+ return
+
+ # Boot the account then delete it.
+ self.msg("Informing and disconnecting account ...")
+ string="\nYour account '%s' is being *permanently* deleted.\n"%username
+ ifreason:
+ string+=" Reason given:\n '%s'"%reason
+ account.msg(string)
+ logger.log_sec(
+ "Account Deleted: %s (Reason: %s, Caller: %s, IP: %s)."
+ %(account,reason,caller,self.session.address)
+ )
+ account.delete()
+ self.msg("Account %s was successfully deleted."%username)
+ return
+
+ # No switches, default to displaying a list of accounts.
+ ifself.argsandself.args.isdigit():
+ nlim=int(self.args)
+ else:
+ nlim=10
+
+ naccounts=AccountDB.objects.count()
+
+ # typeclass table
+ dbtotals=AccountDB.objects.object_totals()
+ typetable=self.styled_table(
+ "|wtypeclass|n","|wcount|n","|w%%|n",border="cells",align="l"
+ )
+ forpath,countindbtotals.items():
+ typetable.add_row(path,count,"%.2f"%((float(count)/naccounts)*100))
+ # last N table
+ plyrs=AccountDB.objects.all().order_by("db_date_created")[max(0,naccounts-nlim):]
+ latesttable=self.styled_table(
+ "|wcreated|n","|wdbref|n","|wname|n","|wtypeclass|n",border="cells",align="l"
+ )
+ forplyinplyrs:
+ latesttable.add_row(
+ utils.datetime_format(ply.date_created),ply.dbref,ply.key,ply.path
+ )
+
+ string="\n|wAccount typeclass distribution:|n\n%s"%typetable
+ string+="\n|wLast %s Accounts created:|n\n%s"%(min(naccounts,nlim),latesttable)
+ caller.msg(string)
+
+
+
[docs]classCmdService(COMMAND_DEFAULT_CLASS):
+ """
+ manage system services
+
+ Usage:
+ service[/switch] <service>
+
+ Switches:
+ list - shows all available services (default)
+ start - activates or reactivate a service
+ stop - stops/inactivate a service (can often be restarted)
+ delete - tries to permanently remove a service
+
+ Service management system. Allows for the listing,
+ starting, and stopping of services. If no switches
+ are given, services will be listed. Note that to operate on the
+ service you have to supply the full (green or red) name as given
+ in the list.
+ """
+
+ key="service"
+ aliases=["services"]
+ switch_options=("list","start","stop","delete")
+ locks="cmd:perm(service) or perm(Developer)"
+ help_category="System"
+
+
[docs]deffunc(self):
+ """Implement command"""
+
+ caller=self.caller
+ switches=self.switches
+
+ ifswitchesandswitches[0]notin("list","start","stop","delete"):
+ caller.msg("Usage: service/<list|start|stop|delete> [servicename]")
+ return
+
+ # get all services
+ service_collection=SESSIONS.server.services
+
+ ifnotswitchesorswitches[0]=="list":
+ # Just display the list of installed services and their
+ # status, then exit.
+ table=self.styled_table(
+ "|wService|n (use services/start|stop|delete)","|wstatus",align="l"
+ )
+ forserviceinservice_collection.services:
+ table.add_row(service.name,service.runningand"|gRunning"or"|rNot Running")
+ caller.msg(str(table))
+ return
+
+ # Get the service to start / stop
+
+ try:
+ service=service_collection.getServiceNamed(self.args)
+ exceptException:
+ string="Invalid service name. This command is case-sensitive. "
+ string+="See service/list for valid service name (enter the full name exactly)."
+ caller.msg(string)
+ return
+
+ ifswitches[0]in("stop","delete"):
+ # Stopping/killing a service gracefully closes it and disconnects
+ # any connections (if applicable).
+
+ delmode=switches[0]=="delete"
+ ifnotservice.running:
+ caller.msg("That service is not currently running.")
+ return
+ ifservice.name[:7]=="Evennia":
+ ifdelmode:
+ caller.msg("You cannot remove a core Evennia service (named 'Evennia*').")
+ return
+ string=("|RYou seem to be shutting down a core Evennia "
+ "service (named 'Evennia*').\nNote that stopping "
+ "some TCP port services will *not* disconnect users "
+ "*already* connected on those ports, but *may* "
+ "instead cause spurious errors for them.\nTo safely "
+ "and permanently remove ports, change settings file "
+ "and restart the server.|n\n")
+ caller.msg(string)
+
+ ifdelmode:
+ service.stopService()
+ service_collection.removeService(service)
+ caller.msg("|gStopped and removed service '%s'.|n"%self.args)
+ else:
+ caller.msg(f"Stopping service '{self.args}'...")
+ try:
+ service.stopService()
+ exceptExceptionaserr:
+ caller.msg(f"|rErrors were reported when stopping this service{err}.\n"
+ "If there are remaining problems, try reloading "
+ "or rebooting the server.")
+ caller.msg("|g... Stopped service '%s'.|n"%self.args)
+ return
+
+ ifswitches[0]=="start":
+ # Attempt to start a service.
+ ifservice.running:
+ caller.msg("That service is already running.")
+ return
+ caller.msg(f"Starting service '{self.args}' ...")
+ try:
+ service.startService()
+ exceptExceptionaserr:
+ caller.msg(f"|rErrors were reported when starting this service{err}.\n"
+ "If there are remaining problems, try reloading the server, changing the "
+ "settings if it's a non-standard service.|n")
+ caller.msg("|gService started.|n")
+
+
+
[docs]classCmdAbout(COMMAND_DEFAULT_CLASS):
+ """
+ show Evennia info
+
+ Usage:
+ about
+
+ Display info about the game engine.
+ """
+
+ key="about"
+ aliases="version"
+ locks="cmd:all()"
+ help_category="System"
+
+
[docs]deffunc(self):
+ """Display information about server or target"""
+
+ string="""
+ |cEvennia|n MU* development system
+
+ |wEvennia version|n: {version}
+ |wOS|n: {os}
+ |wPython|n: {python}
+ |wTwisted|n: {twisted}
+ |wDjango|n: {django}
+
+ |wLicence|n https://opensource.org/licenses/BSD-3-Clause
+ |wWeb|n http://www.evennia.com
+ |wIrc|n #evennia on irc.freenode.net:6667
+ |wForum|n http://www.evennia.com/discussions
+ |wMaintainer|n (2010-) Griatch (griatch AT gmail DOT com)
+ |wMaintainer|n (2006-10) Greg Taylor
+
+ """.format(
+ version=utils.get_evennia_version(),
+ os=os.name,
+ python=sys.version.split()[0],
+ twisted=twisted.version.short(),
+ django=django.get_version(),
+ )
+ self.caller.msg(string)
+
+
+
[docs]classCmdTime(COMMAND_DEFAULT_CLASS):
+ """
+ show server time statistics
+
+ Usage:
+ time
+
+ List Server time statistics such as uptime
+ and the current time stamp.
+ """
+
+ key="time"
+ aliases="uptime"
+ locks="cmd:perm(time) or perm(Player)"
+ help_category="System"
+
+
[docs]deffunc(self):
+ """Show server time data in a table."""
+ table1=self.styled_table("|wServer time","",align="l",width=78)
+ table1.add_row("Current uptime",utils.time_format(gametime.uptime(),3))
+ table1.add_row("Portal uptime",utils.time_format(gametime.portal_uptime(),3))
+ table1.add_row("Total runtime",utils.time_format(gametime.runtime(),2))
+ table1.add_row("First start",datetime.datetime.fromtimestamp(gametime.server_epoch()))
+ table1.add_row("Current time",datetime.datetime.now())
+ table1.reformat_column(0,width=30)
+ table2=self.styled_table(
+ "|wIn-Game time",
+ "|wReal time x %g"%gametime.TIMEFACTOR,
+ align="l",
+ width=77,
+ border_top=0,
+ )
+ epochtxt="Epoch (%s)"%("from settings"ifsettings.TIME_GAME_EPOCHelse"server start")
+ table2.add_row(epochtxt,datetime.datetime.fromtimestamp(gametime.game_epoch()))
+ table2.add_row("Total time passed:",utils.time_format(gametime.gametime(),2))
+ table2.add_row(
+ "Current time ",datetime.datetime.fromtimestamp(gametime.gametime(absolute=True))
+ )
+ table2.reformat_column(0,width=30)
+ self.caller.msg(str(table1)+"\n"+str(table2))
+
+
+
[docs]classCmdServerLoad(COMMAND_DEFAULT_CLASS):
+ """
+ show server load and memory statistics
+
+ Usage:
+ server[/mem]
+
+ Switches:
+ mem - return only a string of the current memory usage
+ flushmem - flush the idmapper cache
+
+ This command shows server load statistics and dynamic memory
+ usage. It also allows to flush the cache of accessed database
+ objects.
+
+ Some Important statistics in the table:
+
+ |wServer load|n is an average of processor usage. It's usually
+ between 0 (no usage) and 1 (100% usage), but may also be
+ temporarily higher if your computer has multiple CPU cores.
+
+ The |wResident/Virtual memory|n displays the total memory used by
+ the server process.
+
+ Evennia |wcaches|n all retrieved database entities when they are
+ loaded by use of the idmapper functionality. This allows Evennia
+ to maintain the same instances of an entity and allowing
+ non-persistent storage schemes. The total amount of cached objects
+ are displayed plus a breakdown of database object types.
+
+ The |wflushmem|n switch allows to flush the object cache. Please
+ note that due to how Python's memory management works, releasing
+ caches may not show you a lower Residual/Virtual memory footprint,
+ the released memory will instead be re-used by the program.
+
+ """
+
+ key="server"
+ aliases=["serverload","serverprocess"]
+ switch_options=("mem","flushmem")
+ locks="cmd:perm(list) or perm(Developer)"
+ help_category="System"
+
+
[docs]deffunc(self):
+ """Show list."""
+
+ global_IDMAPPER
+ ifnot_IDMAPPER:
+ fromevennia.utils.idmapperimportmodelsas_IDMAPPER
+
+ if"flushmem"inself.switches:
+ # flush the cache
+ prev,_=_IDMAPPER.cache_size()
+ nflushed=_IDMAPPER.flush_cache()
+ now,_=_IDMAPPER.cache_size()
+ string=(
+ "The Idmapper cache freed |w{idmapper}|n database objects.\n"
+ "The Python garbage collector freed |w{gc}|n Python instances total."
+ )
+ self.caller.msg(string.format(idmapper=(prev-now),gc=nflushed))
+ return
+
+ # display active processes
+
+ os_windows=os.name=="nt"
+ pid=os.getpid()
+
+ ifos_windows:
+ # Windows requires the psutil module to even get paltry
+ # statistics like this (it's pretty much worthless,
+ # unfortunately, since it's not specific to the process) /rant
+ try:
+ importpsutil
+
+ has_psutil=True
+ exceptImportError:
+ has_psutil=False
+
+ ifhas_psutil:
+ loadavg=psutil.cpu_percent()
+ _mem=psutil.virtual_memory()
+ rmem=_mem.used/(1000.0*1000)
+ pmem=_mem.percent
+
+ if"mem"inself.switches:
+ string="Total computer memory usage: |w%g|n MB (%g%%)"
+ self.caller.msg(string%(rmem,pmem))
+ return
+ # Display table
+ loadtable=self.styled_table("property","statistic",align="l")
+ loadtable.add_row("Total CPU load","%g%%"%loadavg)
+ loadtable.add_row("Total computer memory usage","%g MB (%g%%)"%(rmem,pmem))
+ loadtable.add_row("Process ID","%g"%pid),
+ else:
+ loadtable=(
+ "Not available on Windows without 'psutil' library "
+ "(install with |wpip install psutil|n)."
+ )
+
+ else:
+ # Linux / BSD (OSX) - proper pid-based statistics
+
+ global_RESOURCE
+ ifnot_RESOURCE:
+ importresourceas_RESOURCE
+
+ loadavg=os.getloadavg()[0]
+ rmem=(
+ float(os.popen("ps -p %d -o %s | tail -1"%(pid,"rss")).read())/1000.0
+ )# resident memory
+ vmem=(
+ float(os.popen("ps -p %d -o %s | tail -1"%(pid,"vsz")).read())/1000.0
+ )# virtual memory
+ pmem=float(
+ os.popen("ps -p %d -o %s | tail -1"%(pid,"%mem")).read()
+ )# % of resident memory to total
+ rusage=_RESOURCE.getrusage(_RESOURCE.RUSAGE_SELF)
+
+ if"mem"inself.switches:
+ string="Memory usage: RMEM: |w%g|n MB (%g%%), VMEM (res+swap+cache): |w%g|n MB."
+ self.caller.msg(string%(rmem,pmem,vmem))
+ return
+
+ loadtable=self.styled_table("property","statistic",align="l")
+ loadtable.add_row("Server load (1 min)","%g"%loadavg)
+ loadtable.add_row("Process ID","%g"%pid),
+ loadtable.add_row("Memory usage","%g MB (%g%%)"%(rmem,pmem))
+ loadtable.add_row("Virtual address space","")
+ loadtable.add_row("|x(resident+swap+caching)|n","%g MB"%vmem)
+ loadtable.add_row(
+ "CPU time used (total)",
+ "%s (%gs)"%(utils.time_format(rusage.ru_utime),rusage.ru_utime),
+ )
+ loadtable.add_row(
+ "CPU time used (user)",
+ "%s (%gs)"%(utils.time_format(rusage.ru_stime),rusage.ru_stime),
+ )
+ loadtable.add_row(
+ "Page faults",
+ "%g hard, %g soft, %g swapouts"
+ %(rusage.ru_majflt,rusage.ru_minflt,rusage.ru_nswap),
+ )
+ loadtable.add_row(
+ "Disk I/O","%g reads, %g writes"%(rusage.ru_inblock,rusage.ru_oublock)
+ )
+ loadtable.add_row("Network I/O","%g in, %g out"%(rusage.ru_msgrcv,rusage.ru_msgsnd))
+ loadtable.add_row(
+ "Context switching",
+ "%g vol, %g forced, %g signals"
+ %(rusage.ru_nvcsw,rusage.ru_nivcsw,rusage.ru_nsignals),
+ )
+
+ # os-generic
+
+ string="|wServer CPU and Memory load:|n\n%s"%loadtable
+
+ # object cache count (note that sys.getsiseof is not called so this works for pypy too.
+ total_num,cachedict=_IDMAPPER.cache_size()
+ sorted_cache=sorted(
+ [(key,num)forkey,numincachedict.items()ifnum>0],
+ key=lambdatup:tup[1],
+ reverse=True,
+ )
+ memtable=self.styled_table("entity name","number","idmapper %",align="l")
+ fortupinsorted_cache:
+ memtable.add_row(tup[0],"%i"%tup[1],"%.2f"%(float(tup[1])/total_num*100))
+
+ string+="\n|w Entity idmapper cache:|n %i items\n%s"%(total_num,memtable)
+
+ # return to caller
+ self.caller.msg(string)
+
+
+
[docs]classCmdTickers(COMMAND_DEFAULT_CLASS):
+ """
+ View running tickers
+
+ Usage:
+ tickers
+
+ Note: Tickers are created, stopped and manipulated in Python code
+ using the TickerHandler. This is merely a convenience function for
+ inspecting the current status.
+
+ """
+
+ key="tickers"
+ help_category="System"
+ locks="cmd:perm(tickers) or perm(Builder)"
+
+
+# -*- coding: utf-8 -*-
+"""
+ ** OBS - this is not a normal command module! **
+ ** You cannot import anything in this module as a command! **
+
+This is part of the Evennia unittest framework, for testing the
+stability and integrity of the codebase during updates. This module
+test the default command set. It is instantiated by the
+evennia/objects/tests.py module, which in turn is run by as part of the
+main test suite started with
+ > python game/manage.py test.
+
+"""
+importre
+importtypes
+importdatetime
+fromanythingimportAnything
+
+fromdjango.confimportsettings
+fromunittest.mockimportpatch,Mock,MagicMock
+
+fromevenniaimportDefaultRoom,DefaultExit,ObjectDB
+fromevennia.commands.default.cmdset_characterimportCharacterCmdSet
+fromevennia.utils.test_resourcesimportEvenniaTest
+fromevennia.commands.defaultimport(
+ help,
+ general,
+ system,
+ admin,
+ account,
+ building,
+ batchprocess,
+ comms,
+ unloggedin,
+ syscommands,
+)
+fromevennia.commands.cmdparserimportbuild_matches
+fromevennia.commands.default.muxcommandimportMuxCommand
+fromevennia.commands.commandimportCommand,InterruptCommand
+fromevennia.commandsimportcmdparser
+fromevennia.commands.cmdsetimportCmdSet
+fromevennia.utilsimportansi,utils,gametime
+fromevennia.server.sessionhandlerimportSESSIONS
+fromevenniaimportsearch_object
+fromevenniaimportDefaultObject,DefaultCharacter
+fromevennia.prototypesimportprototypesasprotlib
+
+
+# set up signal here since we are not starting the server
+
+_RE=re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)",re.MULTILINE)
+
+
+# ------------------------------------------------------------
+# Command testing
+# ------------------------------------------------------------
+
+
+
[docs]defcall(
+ self,
+ cmdobj,
+ args,
+ msg=None,
+ cmdset=None,
+ noansi=True,
+ caller=None,
+ receiver=None,
+ cmdstring=None,
+ obj=None,
+ inputs=None,
+ raw_string=None,
+ ):
+ """
+ Test a command by assigning all the needed
+ properties to cmdobj and running
+ cmdobj.at_pre_cmd()
+ cmdobj.parse()
+ cmdobj.func()
+ cmdobj.at_post_cmd()
+ The msgreturn value is compared to eventual
+ output sent to caller.msg in the game
+
+ Returns:
+ msg (str): The received message that was sent to the caller.
+
+ """
+ caller=callerifcallerelseself.char1
+ receiver=receiverifreceiverelsecaller
+ cmdobj.caller=caller
+ cmdobj.cmdname=cmdstringifcmdstringelsecmdobj.key
+ cmdobj.raw_cmdname=cmdobj.cmdname
+ cmdobj.cmdstring=cmdobj.cmdname# deprecated
+ cmdobj.args=args
+ cmdobj.cmdset=cmdset
+ cmdobj.session=SESSIONS.session_from_sessid(1)
+ cmdobj.account=self.account
+ cmdobj.raw_string=raw_stringifraw_stringisnotNoneelsecmdobj.key+" "+args
+ cmdobj.obj=objor(callerifcallerelseself.char1)
+ # test
+ old_msg=receiver.msg
+ inputs=inputsor[]
+
+ try:
+ receiver.msg=Mock()
+ ifcmdobj.at_pre_cmd():
+ return
+ cmdobj.parse()
+ ret=cmdobj.func()
+
+ # handle func's with yield in them (generators)
+ ifisinstance(ret,types.GeneratorType):
+ whileTrue:
+ try:
+ inp=inputs.pop()ifinputselseNone
+ ifinp:
+ try:
+ ret.send(inp)
+ exceptTypeError:
+ next(ret)
+ ret=ret.send(inp)
+ else:
+ next(ret)
+ exceptStopIteration:
+ break
+
+ cmdobj.at_post_cmd()
+ exceptStopIteration:
+ pass
+ exceptInterruptCommand:
+ pass
+
+ # clean out evtable sugar. We only operate on text-type
+ stored_msg=[
+ args[0]ifargsandargs[0]elsekwargs.get("text",utils.to_str(kwargs))
+ forname,args,kwargsinreceiver.msg.mock_calls
+ ]
+ # Get the first element of a tuple if msg received a tuple instead of a string
+ stored_msg=[str(smsg[0])ifisinstance(smsg,tuple)elsestr(smsg)forsmsginstored_msg]
+ ifmsgisnotNone:
+ msg=str(msg)# to be safe, e.g. `py` command may return ints
+ # set our separator for returned messages based on parsing ansi or not
+ msg_sep="|"ifnoansielse"||"
+ # Have to strip ansi for each returned message for the regex to handle it correctly
+ returned_msg=msg_sep.join(
+ _RE.sub("",ansi.parse_ansi(mess,strip_ansi=noansi))formessinstored_msg
+ ).strip()
+ msg=msg.strip()
+ ifmsg==""andreturned_msgornotreturned_msg.startswith(msg):
+ prt=""
+ foric,charinenumerate(msg):
+ importre
+
+ prt+=char
+
+ sep1="\n"+"="*30+"Wanted message"+"="*34+"\n"
+ sep2="\n"+"="*30+"Returned message"+"="*32+"\n"
+ sep3="\n"+"="*78
+ retval=sep1+msg+sep2+returned_msg+sep3
+ raiseAssertionError(retval)
+ else:
+ returned_msg="\n".join(str(msg)formsginstored_msg)
+ returned_msg=ansi.parse_ansi(returned_msg,strip_ansi=noansi).strip()
+ receiver.msg=old_msg
+
+ returnreturned_msg
[docs]deftest_help(self):
+ self.call(help.CmdHelp(),"","Command help entries",cmdset=CharacterCmdSet())
+
+
[docs]deftest_set_help(self):
+ self.call(
+ help.CmdSetHelp(),
+ "testhelp, General = This is a test",
+ "Topic 'testhelp' was successfully created.",
+ )
+ self.call(help.CmdHelp(),"testhelp","Help for testhelp",cmdset=CharacterCmdSet())
[docs]deftest_py(self):
+ # we are not testing CmdReload, CmdReset and CmdShutdown, CmdService or CmdTime
+ # since the server is not running during these tests.
+ self.call(system.CmdPy(),"1+2",">>> 1+2|3")
+ self.call(system.CmdPy(),"/clientraw 1+2",">>> 1+2|3")
[docs]deftest_char_create(self):
+ self.call(
+ account.CmdCharCreate(),
+ "Test1=Test char",
+ "Created new character Test1. Use ic Test1 to enter the game",
+ caller=self.account,
+ )
+
+
[docs]deftest_char_delete(self):
+ # Chardelete requires user input; this test is mainly to confirm
+ # whether permissions are being checked
+
+ # Add char to account playable characters
+ self.account.db._playable_characters.append(self.char1)
+
+ # Try deleting as Developer
+ self.call(
+ account.CmdCharDelete(),
+ "Char",
+ "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?",
+ caller=self.account,
+ )
+
+ # Downgrade permissions on account
+ self.account.permissions.add("Player")
+ self.account.permissions.remove("Developer")
+
+ # Set lock on character object to prevent deletion
+ self.char1.locks.add("delete:none()")
+
+ # Try deleting as Player
+ self.call(
+ account.CmdCharDelete(),
+ "Char",
+ "You do not have permission to delete this character.",
+ caller=self.account,
+ )
+
+ # Set lock on character object to allow self-delete
+ self.char1.locks.add("delete:pid(%i)"%self.account.id)
+
+ # Try deleting as Player again
+ self.call(
+ account.CmdCharDelete(),
+ "Char",
+ "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?",
+ caller=self.account,
+ )
+
+
[docs]deftest_quell(self):
+ self.call(
+ account.CmdQuell(),
+ "",
+ "Quelling to current puppet's permissions (developer).",
+ caller=self.account,
+ )
[docs]deftest_empty_desc(self):
+ """
+ empty desc sets desc as ''
+ """
+ oid=self.obj2.id
+ o2d=self.obj2.db.desc
+ r1d=self.room1.db.desc
+ self.call(building.CmdDesc(),"Obj2=","The description was set on Obj2(#{}).".format(oid))
+ assertself.obj2.db.desc==""andself.obj2.db.desc!=o2d
+ assertself.room1.db.desc==r1d
+
+
[docs]deftest_desc_default_to_room(self):
+ """no rhs changes room's desc"""
+ rid=self.room1.id
+ o2d=self.obj2.db.desc
+ r1d=self.room1.db.desc
+ self.call(building.CmdDesc(),"Obj2","The description was set on Room(#{}).".format(rid))
+ assertself.obj2.db.desc==o2d
+ assertself.room1.db.desc=="Obj2"andself.room1.db.desc!=r1d
+
+
[docs]deftest_destroy(self):
+ confirm=building.CmdDestroy.confirm
+ building.CmdDestroy.confirm=False
+ self.call(building.CmdDestroy(),"","Usage: ")
+ self.call(building.CmdDestroy(),"Obj","Obj was destroyed.")
+ self.call(building.CmdDestroy(),"Obj","Obj2 was destroyed.")
+ self.call(
+ building.CmdDestroy(),
+ "Obj",
+ "Could not find 'Obj'.| (Objects to destroy "
+ "must either be local or specified with a unique #dbref.)",
+ )
+ self.call(
+ building.CmdDestroy(),settings.DEFAULT_HOME,"You are trying to delete"
+ )# DEFAULT_HOME should not be deleted
+ self.char2.location=self.room2
+ charid=self.char2.id
+ room1id=self.room1.id
+ room2id=self.room2.id
+ self.call(
+ building.CmdDestroy(),
+ self.room2.dbref,
+ "Char2(#{}) arrives to Room(#{}) from Room2(#{}).|Room2 was destroyed.".format(
+ charid,room1id,room2id
+ ),
+ )
+ building.CmdDestroy.confirm=confirm
+
+
[docs]deftest_destroy_sequence(self):
+ confirm=building.CmdDestroy.confirm
+ building.CmdDestroy.confirm=False
+ self.call(
+ building.CmdDestroy(),
+ "{}-{}".format(self.obj1.dbref,self.obj2.dbref),
+ "Obj was destroyed.\nObj2 was destroyed.",
+ )
[docs]deftest_exit_commands(self):
+ self.call(
+ building.CmdOpen(),"TestExit1=Room2","Created new Exit 'TestExit1' from Room to Room2"
+ )
+ self.call(building.CmdLink(),"TestExit1=Room","Link created TestExit1 -> Room (one way).")
+ self.call(building.CmdUnLink(),"","Usage: ")
+ self.call(building.CmdLink(),"NotFound","Could not find 'NotFound'.")
+ self.call(building.CmdLink(),"TestExit","TestExit1 is an exit to Room.")
+ self.call(building.CmdLink(),"Obj","Obj is not an exit. Its home location is Room.")
+ self.call(
+ building.CmdUnLink(),"TestExit1","Former exit TestExit1 no longer links anywhere."
+ )
+
+ self.char1.location=self.room2
+ self.call(
+ building.CmdOpen(),"TestExit2=Room","Created new Exit 'TestExit2' from Room2 to Room."
+ )
+ self.call(
+ building.CmdOpen(),
+ "TestExit2=Room",
+ "Exit TestExit2 already exists. It already points to the correct place.",
+ )
+
+ # ensure it matches locally first
+ self.call(
+ building.CmdLink(),"TestExit=Room2","Link created TestExit2 -> Room2 (one way)."
+ )
+ self.call(
+ building.CmdLink(),
+ "/twoway TestExit={}".format(self.exit.dbref),
+ "Link created TestExit2 (in Room2) <-> out (in Room) (two-way).",
+ )
+ self.call(
+ building.CmdLink(),
+ "/twoway TestExit={}".format(self.room1.dbref),
+ "To create a two-way link, TestExit2 and Room must both have a location ",
+ )
+ self.call(
+ building.CmdLink(),
+ "/twoway {}={}".format(self.exit.dbref,self.exit.dbref),
+ "Cannot link an object to itself.",
+ )
+ self.call(building.CmdLink(),"","Usage: ")
+ # ensure can still match globally when not a local name
+ self.call(building.CmdLink(),"TestExit1=Room2","Note: TestExit1")
+ self.call(
+ building.CmdLink(),"TestExit1=","Former exit TestExit1 no longer links anywhere."
+ )
+
+
[docs]deftest_set_home(self):
+ self.call(
+ building.CmdSetHome(),"Obj = Room2","Home location of Obj was changed from Room"
+ )
+ self.call(building.CmdSetHome(),"","Usage: ")
+ self.call(building.CmdSetHome(),"self","Char's current home is Room")
+ self.call(building.CmdSetHome(),"Obj","Obj's current home is Room2")
+ self.obj1.home=None
+ self.call(building.CmdSetHome(),"Obj = Room2","Home location of Obj was set to Room")
[docs]deftest_page(self):
+ self.call(
+ comms.CmdPage(),
+ "TestAccount2 = Test",
+ "TestAccount2 is offline. They will see your message if they list their pages later."
+ "|You paged TestAccount2 with: 'Test'.",
+ receiver=self.account,
+ )
+
+
[docs]deftest_cboot(self):
+ # No one else connected to boot
+ self.call(
+ comms.CmdCBoot(),
+ "",
+ "Usage: cboot[/quiet] <channel> = <account> [:reason]",
+ receiver=self.account,
+ )
+
+
[docs]deftest_cdestroy(self):
+ self.call(
+ comms.CmdCdestroy(),
+ "testchan",
+ "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases."
+ "|Channel 'testchan' was destroyed.",
+ receiver=self.account,
+ )
[docs]deftest_batch_commands(self):
+ # cannot test batchcode here, it must run inside the server process
+ self.call(
+ batchprocess.CmdBatchCommands(),
+ "example_batch_cmds",
+ "Running Batch-command processor - Automatic mode for example_batch_cmds",
+ )
+ # we make sure to delete the button again here to stop the running reactor
+ confirm=building.CmdDestroy.confirm
+ building.CmdDestroy.confirm=False
+ self.call(building.CmdDestroy(),"button","button was destroyed.")
+ building.CmdDestroy.confirm=confirm
[docs]deftest_info_command(self):
+ # instead of using SERVER_START_TIME (0), we use 86400 because Windows won't let us use anything lower
+ gametime.SERVER_START_TIME=86400
+ expected=(
+ "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO"
+ %(
+ settings.SERVERNAME,
+ datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
+ SESSIONS.account_count(),
+ utils.get_evennia_version(),
+ )
+ )
+ self.call(unloggedin.CmdUnconnectedInfo(),"",expected)
+ delgametime.SERVER_START_TIME
Source code for evennia.commands.default.unloggedin
+"""
+Commands that are available from the connect screen.
+"""
+importre
+importdatetime
+fromcodecsimportlookupascodecs_lookup
+fromdjango.confimportsettings
+fromevennia.comms.modelsimportChannelDB
+fromevennia.server.sessionhandlerimportSESSIONS
+
+fromevennia.utilsimportclass_from_module,create,logger,utils,gametime
+fromevennia.commands.cmdhandlerimportCMD_LOGINSTART
+
+COMMAND_DEFAULT_CLASS=utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# limit symbol import for API
+__all__=(
+ "CmdUnconnectedConnect",
+ "CmdUnconnectedCreate",
+ "CmdUnconnectedQuit",
+ "CmdUnconnectedLook",
+ "CmdUnconnectedHelp",
+ "CmdUnconnectedEncoding",
+ "CmdUnconnectedInfo",
+ "CmdUnconnectedScreenreader"
+)
+
+MULTISESSION_MODE=settings.MULTISESSION_MODE
+CONNECTION_SCREEN_MODULE=settings.CONNECTION_SCREEN_MODULE
+
+
+defcreate_guest_account(session):
+ """
+ Creates a guest account/character for this session, if one is available.
+
+ Args:
+ session (Session): the session which will use the guest account/character.
+
+ Returns:
+ GUEST_ENABLED (boolean), account (Account):
+ the boolean is whether guest accounts are enabled at all.
+ the Account which was created from an available guest name.
+ """
+ enabled=settings.GUEST_ENABLED
+ address=session.address
+
+ # Get account class
+ Guest=class_from_module(settings.BASE_GUEST_TYPECLASS)
+
+ # Get an available guest account
+ # authenticate() handles its own throttling
+ account,errors=Guest.authenticate(ip=address)
+ ifaccount:
+ returnenabled,account
+ else:
+ session.msg("|R%s|n"%"\n".join(errors))
+ returnenabled,None
+
+
+defcreate_normal_account(session,name,password):
+ """
+ Creates an account with the given name and password.
+
+ Args:
+ session (Session): the session which is requesting to create an account.
+ name (str): the name that the account wants to use for login.
+ password (str): the password desired by this account, for login.
+
+ Returns:
+ account (Account): the account which was created from the name and password.
+ """
+ # Get account class
+ Account=class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
+
+ address=session.address
+
+ # Match account name and check password
+ # authenticate() handles all its own throttling
+ account,errors=Account.authenticate(
+ username=name,password=password,ip=address,session=session
+ )
+ ifnotaccount:
+ # No accountname or password match
+ session.msg("|R%s|n"%"\n".join(errors))
+ returnNone
+
+ returnaccount
+
+
+
[docs]classCmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
+ """
+ connect to the game
+
+ Usage (at login screen):
+ connect accountname password
+ connect "account name" "pass word"
+
+ Use the create command to first create an account before logging in.
+
+ If you have spaces in your name, enclose it in double quotes.
+ """
+
+ key="connect"
+ aliases=["conn","con","co"]
+ locks="cmd:all()"# not really needed
+ arg_regex=r"\s.*?|$"
+
+
[docs]deffunc(self):
+ """
+ Uses the Django admin api. Note that unlogged-in commands
+ have a unique position in that their func() receives
+ a session object instead of a source_object like all
+ other types of logged-in commands (this is because
+ there is no object yet before the account has logged in)
+ """
+ session=self.caller
+ address=session.address
+
+ args=self.args
+ # extract double quote parts
+ parts=[part.strip()forpartinre.split(r"\"",args)ifpart.strip()]
+ iflen(parts)==1:
+ # this was (hopefully) due to no double quotes being found, or a guest login
+ parts=parts[0].split(None,1)
+
+ # Guest login
+ iflen(parts)==1andparts[0].lower()=="guest":
+ # Get Guest typeclass
+ Guest=class_from_module(settings.BASE_GUEST_TYPECLASS)
+
+ account,errors=Guest.authenticate(ip=address)
+ ifaccount:
+ session.sessionhandler.login(session,account)
+ return
+ else:
+ session.msg("|R%s|n"%"\n".join(errors))
+ return
+
+ iflen(parts)!=2:
+ session.msg("\n\r Usage (without <>): connect <name> <password>")
+ return
+
+ # Get account class
+ Account=class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
+
+ name,password=parts
+ account,errors=Account.authenticate(
+ username=name,password=password,ip=address,session=session
+ )
+ ifaccount:
+ session.sessionhandler.login(session,account)
+ else:
+ session.msg("|R%s|n"%"\n".join(errors))
+
+
+
[docs]classCmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
+ """
+ create a new account account
+
+ Usage (at login screen):
+ create <accountname> <password>
+ create "account name" "pass word"
+
+ This creates a new account account.
+
+ If you have spaces in your name, enclose it in double quotes.
+ """
+
+ key="create"
+ aliases=["cre","cr"]
+ locks="cmd:all()"
+ arg_regex=r"\s.*?|$"
+
+
[docs]deffunc(self):
+ """Do checks and create account"""
+
+ session=self.caller
+ args=self.args.strip()
+
+ address=session.address
+
+ # Get account class
+ Account=class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
+
+ # extract double quoted parts
+ parts=[part.strip()forpartinre.split(r"\"",args)ifpart.strip()]
+ iflen(parts)==1:
+ # this was (hopefully) due to no quotes being found
+ parts=parts[0].split(None,1)
+ iflen(parts)!=2:
+ string=(
+ "\n Usage (without <>): create <name> <password>"
+ "\nIf <name> or <password> contains spaces, enclose it in double quotes."
+ )
+ session.msg(string)
+ return
+
+ username,password=parts
+
+ # everything's ok. Create the new account account.
+ account,errors=Account.create(
+ username=username,password=password,ip=address,session=session
+ )
+ ifaccount:
+ # tell the caller everything went well.
+ string="A new account '%s' was created. Welcome!"
+ if" "inusername:
+ string+=(
+ "\n\nYou can now log in with the command 'connect \"%s\" <your password>'."
+ )
+ else:
+ string+="\n\nYou can now log with the command 'connect %s <your password>'."
+ session.msg(string%(username,username))
+ else:
+ session.msg("|R%s|n"%"\n".join(errors))
+
+
+
[docs]classCmdUnconnectedQuit(COMMAND_DEFAULT_CLASS):
+ """
+ quit when in unlogged-in state
+
+ Usage:
+ quit
+
+ We maintain a different version of the quit command
+ here for unconnected accounts for the sake of simplicity. The logged in
+ version is a bit more complicated.
+ """
+
+ key="quit"
+ aliases=["q","qu"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Simply close the connection."""
+ session=self.caller
+ session.sessionhandler.disconnect(session,"Good bye! Disconnecting.")
+
+
+
[docs]classCmdUnconnectedLook(COMMAND_DEFAULT_CLASS):
+ """
+ look when in unlogged-in state
+
+ Usage:
+ look
+
+ This is an unconnected version of the look command for simplicity.
+
+ This is called by the server and kicks everything in gear.
+ All it does is display the connect screen.
+ """
+
+ key=CMD_LOGINSTART
+ aliases=["look","l"]
+ locks="cmd:all()"
+
+
[docs]classCmdUnconnectedHelp(COMMAND_DEFAULT_CLASS):
+ """
+ get help when in unconnected-in state
+
+ Usage:
+ help
+
+ This is an unconnected version of the help command,
+ for simplicity. It shows a pane of info.
+ """
+
+ key="help"
+ aliases=["h","?"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Shows help"""
+
+ string="""
+You are not yet logged into the game. Commands available at this point:
+
+ |wcreate|n - create a new account
+ |wconnect|n - connect with an existing account
+ |wlook|n - re-show the connection screen
+ |whelp|n - show this help
+ |wencoding|n - change the text encoding to match your client
+ |wscreenreader|n - make the server more suitable for use with screen readers
+ |wquit|n - abort the connection
+
+First create an account e.g. with |wcreate Anna c67jHL8p|n
+Next you can connect to the game: |wconnect Anna c67jHL8p|n
+
+You can use the |wlook|n command if you want to see the connect screen again.
+
+"""
+
+ ifsettings.STAFF_CONTACT_EMAIL:
+ string+="For support, please contact: %s"%settings.STAFF_CONTACT_EMAIL
+ self.caller.msg(string)
+
+
+
[docs]classCmdUnconnectedEncoding(COMMAND_DEFAULT_CLASS):
+ """
+ set which text encoding to use in unconnected-in state
+
+ Usage:
+ encoding/switches [<encoding>]
+
+ Switches:
+ clear - clear your custom encoding
+
+
+ This sets the text encoding for communicating with Evennia. This is mostly
+ an issue only if you want to use non-ASCII characters (i.e. letters/symbols
+ not found in English). If you see that your characters look strange (or you
+ get encoding errors), you should use this command to set the server
+ encoding to be the same used in your client program.
+
+ Common encodings are utf-8 (default), latin-1, ISO-8859-1 etc.
+
+ If you don't submit an encoding, the current encoding will be displayed
+ instead.
+ """
+
+ key="encoding"
+ aliases="encode"
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """
+ Sets the encoding.
+ """
+
+ ifself.sessionisNone:
+ return
+
+ sync=False
+ if"clear"inself.switches:
+ # remove customization
+ old_encoding=self.session.protocol_flags.get("ENCODING",None)
+ ifold_encoding:
+ string="Your custom text encoding ('%s') was cleared."%old_encoding
+ else:
+ string="No custom encoding was set."
+ self.session.protocol_flags["ENCODING"]="utf-8"
+ sync=True
+ elifnotself.args:
+ # just list the encodings supported
+ pencoding=self.session.protocol_flags.get("ENCODING",None)
+ string=""
+ ifpencoding:
+ string+=(
+ "Default encoding: |g%s|n (change with |wencoding <encoding>|n)"%pencoding
+ )
+ encodings=settings.ENCODINGS
+ ifencodings:
+ string+=(
+ "\nServer's alternative encodings (tested in this order):\n |g%s|n"
+ %", ".join(encodings)
+ )
+ ifnotstring:
+ string="No encodings found."
+ else:
+ # change encoding
+ old_encoding=self.session.protocol_flags.get("ENCODING",None)
+ encoding=self.args
+ try:
+ codecs_lookup(encoding)
+ exceptLookupError:
+ string=(
+ "|rThe encoding '|w%s|r' is invalid. Keeping the previous encoding '|w%s|r'.|n"
+ %(encoding,old_encoding)
+ )
+ else:
+ self.session.protocol_flags["ENCODING"]=encoding
+ string="Your custom text encoding was changed from '|w%s|n' to '|w%s|n'."%(
+ old_encoding,
+ encoding,
+ )
+ sync=True
+ ifsync:
+ self.session.sessionhandler.session_portal_sync(self.session)
+ self.caller.msg(string.strip())
+
+
+
[docs]classCmdUnconnectedScreenreader(COMMAND_DEFAULT_CLASS):
+ """
+ Activate screenreader mode.
+
+ Usage:
+ screenreader
+
+ Used to flip screenreader mode on and off before logging in (when
+ logged in, use option screenreader on).
+ """
+
+ key="screenreader"
+
+
[docs]classCmdUnconnectedInfo(COMMAND_DEFAULT_CLASS):
+ """
+ Provides MUDINFO output, so that Evennia games can be added to Mudconnector
+ and Mudstats. Sadly, the MUDINFO specification seems to have dropped off the
+ face of the net, but it is still used by some crawlers. This implementation
+ was created by looking at the MUDINFO implementation in MUX2, TinyMUSH, Rhost,
+ and PennMUSH.
+ """
+
+ key="info"
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ self.caller.msg(
+ "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO"
+ %(
+ settings.SERVERNAME,
+ datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
+ SESSIONS.account_count(),
+ utils.get_evennia_version(),
+ )
+ )
+
+
+def_create_account(session,accountname,password,permissions,typeclass=None,email=None):
+ """
+ Helper function, creates an account of the specified typeclass.
+ """
+ try:
+ new_account=create.create_account(
+ accountname,email,password,permissions=permissions,typeclass=typeclass
+ )
+
+ exceptExceptionase:
+ session.msg(
+ "There was an error creating the Account:\n%s\n If this problem persists, contact an admin."
+ %e
+ )
+ logger.log_trace()
+ returnFalse
+
+ # This needs to be set so the engine knows this account is
+ # logging in for the first time. (so it knows to call the right
+ # hooks during login later)
+ new_account.db.FIRST_LOGIN=True
+
+ # join the new account to the public channel
+ pchannel=ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"])
+ ifnotpchannelornotpchannel.connect(new_account):
+ string="New account '%s' could not connect to public channel!"%new_account.key
+ logger.log_err(string)
+ returnnew_account
+
+
+def_create_character(session,new_account,typeclass,home,permissions):
+ """
+ Helper function, creates a character based on an account's name.
+ This is meant for Guest and MULTISESSION_MODE < 2 situations.
+ """
+ try:
+ new_character=create.create_object(
+ typeclass,key=new_account.key,home=home,permissions=permissions
+ )
+ # set playable character list
+ new_account.db._playable_characters.append(new_character)
+
+ # allow only the character itself and the account to puppet this character (and Developers).
+ new_character.locks.add(
+ "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)"
+ %(new_character.id,new_account.id)
+ )
+
+ # If no description is set, set a default description
+ ifnotnew_character.db.desc:
+ new_character.db.desc="This is a character."
+ # We need to set this to have ic auto-connect to this character
+ new_account.db._last_puppet=new_character
+ exceptExceptionase:
+ session.msg(
+ "There was an error creating the Character:\n%s\n If this problem persists, contact an admin."
+ %e
+ )
+ logger.log_trace()
+
+"""
+This defines how Comm models are displayed in the web admin interface.
+
+"""
+
+fromdjango.contribimportadmin
+fromevennia.comms.modelsimportChannelDB
+fromevennia.typeclasses.adminimportAttributeInline,TagInline
+fromdjango.confimportsettings
+
+
+
[docs]defsubscriptions(self,obj):
+ """
+ Helper method to get subs from a channel.
+
+ Args:
+ obj (Channel): The channel to get subs from.
+
+ """
+ return", ".join([str(sub)forsubinobj.subscriptions.all()])
+
+
[docs]defsave_model(self,request,obj,form,change):
+ """
+ Model-save hook.
+
+ Args:
+ request (Request): Incoming request.
+ obj (Object): Database object.
+ form (Form): Form instance.
+ change (bool): If this is a change or a new object.
+
+ """
+ obj.save()
+ ifnotchange:
+ # adding a new object
+ # have to call init with typeclass passed to it
+ obj.set_class_from_typeclass(typeclass_path=settings.BASE_CHANNEL_TYPECLASS)
+ obj.at_init()
+"""
+The channel handler, accessed from this module as CHANNEL_HANDLER is a
+singleton that handles the stored set of channels and how they are
+represented against the cmdhandler.
+
+If there is a channel named 'newbie', we want to be able to just write
+
+ newbie Hello!
+
+For this to work, 'newbie', the name of the channel, must be
+identified by the cmdhandler as a command name. The channelhandler
+stores all channels as custom 'commands' that the cmdhandler can
+import and look through.
+
+> Warning - channel names take precedence over command names, so make
+sure to not pick clashing channel names.
+
+Unless deleting a channel you normally don't need to bother about the
+channelhandler at all - the create_channel method handles the update.
+
+To delete a channel cleanly, delete the channel object, then call
+update() on the channelhandler. Or use Channel.objects.delete() which
+does this for you.
+
+"""
+fromdjango.confimportsettings
+fromevennia.commandsimportcmdset
+fromevennia.utils.loggerimporttail_log_file
+fromevennia.utils.utilsimportclass_from_module
+fromdjango.utils.translationimportgettextas_
+
+# we must late-import these since any overloads are likely to
+# themselves be using these classes leading to a circular import.
+
+_CHANNEL_HANDLER_CLASS=None
+_CHANNEL_COMMAND_CLASS=None
+_CHANNELDB=None
+_COMMAND_DEFAULT_CLASS=class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+
[docs]classChannelCommand(_COMMAND_DEFAULT_CLASS):
+ """
+ {channelkey} channel
+
+ {channeldesc}
+
+ Usage:
+ {lower_channelkey} <message>
+ {lower_channelkey}/history [start]
+ {lower_channelkey} off - mutes the channel
+ {lower_channelkey} on - unmutes the channel
+
+ Switch:
+ history: View 20 previous messages, either from the end or
+ from <start> number of messages from the end.
+
+ Example:
+ {lower_channelkey} Hello World!
+ {lower_channelkey}/history
+ {lower_channelkey}/history 30
+
+ """
+
+ # ^note that channeldesc and lower_channelkey will be filled
+ # automatically by ChannelHandler
+
+ # this flag is what identifies this cmd as a channel cmd
+ # and branches off to the system send-to-channel command
+ # (which is customizable by admin)
+ is_channel=True
+ key="general"
+ help_category="Channel Names"
+ obj=None
+ arg_regex=r"\s.*?|/history.*?"
+
+
[docs]defparse(self):
+ """
+ Simple parser
+ """
+ # cmdhandler sends channame:msg here.
+ channelname,msg=self.args.split(":",1)
+ self.history_start=None
+ ifmsg.startswith("/history"):
+ arg=msg[8:]
+ try:
+ self.history_start=int(arg)ifargelse0
+ exceptValueError:
+ # if no valid number was given, ignore it
+ pass
+ self.args=(channelname.strip(),msg.strip())
+
+
[docs]deffunc(self):
+ """
+ Create a new message and send it to channel, using
+ the already formatted input.
+ """
+ global_CHANNELDB
+ ifnot_CHANNELDB:
+ fromevennia.comms.modelsimportChannelDBas_CHANNELDB
+
+ channelkey,msg=self.args
+ caller=self.caller
+ ifnotmsg:
+ self.msg(_("Say what?"))
+ return
+ channel=_CHANNELDB.objects.get_channel(channelkey)
+
+ ifnotchannel:
+ self.msg(_("Channel '%s' not found.")%channelkey)
+ return
+ ifnotchannel.has_connection(caller):
+ string=_("You are not connected to channel '%s'.")
+ self.msg(string%channelkey)
+ return
+ ifnotchannel.access(caller,"send"):
+ string=_("You are not permitted to send to channel '%s'.")
+ self.msg(string%channelkey)
+ return
+ ifmsg=="on":
+ caller=callerifnothasattr(caller,"account")elsecaller.account
+ unmuted=channel.unmute(caller)
+ ifunmuted:
+ self.msg(_("You start listening to %s.")%channel)
+ return
+ self.msg(_("You were already listening to %s.")%channel)
+ return
+ ifmsg=="off":
+ caller=callerifnothasattr(caller,"account")elsecaller.account
+ muted=channel.mute(caller)
+ ifmuted:
+ self.msg(_("You stop listening to %s.")%channel)
+ return
+ self.msg(_("You were already not listening to %s.")%channel)
+ return
+ ifself.history_startisnotNone:
+ # Try to view history
+ log_file=channel.attributes.get("log_file",default="channel_%s.log"%channel.key)
+
+ defsend_msg(lines):
+ returnself.msg(
+ "".join(line.split("[-]",1)[1]if"[-]"inlineelselineforlineinlines)
+ )
+
+ tail_log_file(log_file,self.history_start,20,callback=send_msg)
+ else:
+ caller=callerifnothasattr(caller,"account")elsecaller.account
+ ifcallerinchannel.mutelist:
+ self.msg(_("You currently have %s muted.")%channel)
+ return
+ channel.msg(msg,senders=self.caller,online=True)
+
+
[docs]defget_extra_info(self,caller,**kwargs):
+ """
+ Let users know that this command is for communicating on a channel.
+
+ Args:
+ caller (TypedObject): A Character or Account who has entered an ambiguous command.
+
+ Returns:
+ A string with identifying information to disambiguate the object, conventionally with a preceding space.
+ """
+ return_(" (channel)")
+
+
+
[docs]classChannelHandler(object):
+ """
+ The ChannelHandler manages all active in-game channels and
+ dynamically creates channel commands for users so that they can
+ just give the channel's key or alias to write to it. Whenever a
+ new channel is created in the database, the update() method on
+ this handler must be called to sync it with the database (this is
+ done automatically if creating the channel with
+ evennia.create_channel())
+
+ """
+
+
[docs]defadd(self,channel):
+ """
+ Add an individual channel to the handler. This is called
+ whenever a new channel is created.
+
+ Args:
+ channel (Channel): The channel to add.
+
+ Notes:
+ To remove a channel, simply delete the channel object and
+ run self.update on the handler. This should usually be
+ handled automatically by one of the deletion methos of
+ the Channel itself.
+
+ """
+ global_CHANNEL_COMMAND_CLASS
+ ifnot_CHANNEL_COMMAND_CLASS:
+ _CHANNEL_COMMAND_CLASS=class_from_module(settings.CHANNEL_COMMAND_CLASS)
+
+ # map the channel to a searchable command
+ cmd=_CHANNEL_COMMAND_CLASS(
+ key=channel.key.strip().lower(),
+ aliases=channel.aliases.all(),
+ locks="cmd:all();%s"%channel.locks,
+ help_category="Channel names",
+ obj=channel,
+ is_channel=True,
+ )
+ # format the help entry
+ key=channel.key
+ cmd.__doc__=cmd.__doc__.format(
+ channelkey=key,
+ lower_channelkey=key.strip().lower(),
+ channeldesc=channel.attributes.get("desc",default="").strip(),
+ )
+ self._cached_channel_cmds[channel]=cmd
+ self._cached_channels[key]=channel
+ self._cached_cmdsets={}
+
+ add_channel=add# legacy alias
+
+
[docs]defremove(self,channel):
+ """
+ Remove channel from channelhandler. This will also delete it.
+
+ Args:
+ channel (Channel): Channel to remove/delete.
+
+ """
+ ifchannel.pk:
+ channel.delete()
+ self.update()
+
+
[docs]defupdate(self):
+ """
+ Updates the handler completely, including removing old removed
+ Channel objects. This must be called after deleting a Channel.
+
+ """
+ global_CHANNELDB
+ ifnot_CHANNELDB:
+ fromevennia.comms.modelsimportChannelDBas_CHANNELDB
+ self._cached_channel_cmds={}
+ self._cached_cmdsets={}
+ self._cached_channels={}
+ forchannelin_CHANNELDB.objects.get_all_channels():
+ self.add(channel)
+
+
[docs]defget(self,channelname=None):
+ """
+ Get a channel from the handler, or all channels
+
+ Args:
+ channelame (str, optional): Channel key, case insensitive.
+ Returns
+ channels (list): The matching channels in a list, or all
+ channels in the handler.
+
+ """
+ ifchannelname:
+ channel=self._cached_channels.get(channelname.lower(),None)
+ return[channel]ifchannelelse[]
+ returnlist(self._cached_channels.values())
+
+
[docs]defget_cmdset(self,source_object):
+ """
+ Retrieve cmdset for channels this source_object has
+ access to send to.
+
+ Args:
+ source_object (Object): An object subscribing to one
+ or more channels.
+
+ Returns:
+ cmdsets (list): The Channel-Cmdsets `source_object` has
+ access to.
+
+ """
+ ifsource_objectinself._cached_cmdsets:
+ returnself._cached_cmdsets[source_object]
+ else:
+ # create a new cmdset holding all viable channels
+ chan_cmdset=None
+ chan_cmds=[
+ channelcmd
+ forchannel,channelcmdinself._cached_channel_cmds.items()
+ ifchannel.subscriptions.has(source_object)
+ andchannelcmd.access(source_object,"send")
+ ]
+ ifchan_cmds:
+ chan_cmdset=cmdset.CmdSet()
+ chan_cmdset.key="ChannelCmdSet"
+ chan_cmdset.priority=101
+ chan_cmdset.duplicates=True
+ forcmdinchan_cmds:
+ chan_cmdset.add(cmd)
+ self._cached_cmdsets[source_object]=chan_cmdset
+ returnchan_cmdset
+
+
+# set up the singleton
+CHANNEL_HANDLER=class_from_module(settings.CHANNEL_HANDLER_CLASS)()
+CHANNELHANDLER=CHANNEL_HANDLER# legacy
+
[docs]classDefaultChannel(ChannelDB,metaclass=TypeclassBase):
+ """
+ This is the base class for all Channel Comms. Inherit from this to
+ create different types of communication channels.
+
+ """
+
+ objects=ChannelManager()
+
+
[docs]defat_first_save(self):
+ """
+ Called by the typeclass system the very first time the channel
+ is saved to the database. Generally, don't overload this but
+ the hooks called by this method.
+
+ """
+ self.basetype_setup()
+ self.at_channel_creation()
+ self.attributes.add("log_file","channel_%s.log"%self.key)
+ ifhasattr(self,"_createdict"):
+ # this is only set if the channel was created
+ # with the utils.create.create_channel function.
+ cdict=self._createdict
+ ifnotcdict.get("key"):
+ ifnotself.db_key:
+ self.db_key="#i"%self.dbid
+ elifcdict["key"]andself.key!=cdict["key"]:
+ self.key=cdict["key"]
+ ifcdict.get("aliases"):
+ self.aliases.add(cdict["aliases"])
+ ifcdict.get("locks"):
+ self.locks.add(cdict["locks"])
+ ifcdict.get("keep_log"):
+ self.attributes.add("keep_log",cdict["keep_log"])
+ ifcdict.get("desc"):
+ self.attributes.add("desc",cdict["desc"])
+ ifcdict.get("tags"):
+ self.tags.batch_add(*cdict["tags"])
+
+
[docs]defbasetype_setup(self):
+ # delayed import of the channelhandler
+ global_CHANNEL_HANDLER
+ ifnot_CHANNEL_HANDLER:
+ fromevennia.comms.channelhandlerimportCHANNEL_HANDLERas_CHANNEL_HANDLER
+ # register ourselves with the channelhandler.
+ _CHANNEL_HANDLER.add(self)
+
+ self.locks.add("send:all();listen:all();control:perm(Admin)")
+
+
[docs]defat_channel_creation(self):
+ """
+ Called once, when the channel is first created.
+
+ """
+ pass
+
+ # helper methods, for easy overloading
+
+
[docs]defhas_connection(self,subscriber):
+ """
+ Checks so this account is actually listening
+ to this channel.
+
+ Args:
+ subscriber (Account or Object): Entity to check.
+
+ Returns:
+ has_sub (bool): Whether the subscriber is subscribing to
+ this channel or not.
+
+ Notes:
+ This will first try Account subscribers and only try Object
+ if the Account fails.
+
+ """
+ has_sub=self.subscriptions.has(subscriber)
+ ifnothas_subandhasattr(subscriber,"account"):
+ # it's common to send an Object when we
+ # by default only allow Accounts to subscribe.
+ has_sub=self.subscriptions.has(subscriber.account)
+ returnhas_sub
[docs]defmute(self,subscriber,**kwargs):
+ """
+ Adds an entity to the list of muted subscribers.
+ A muted subscriber will no longer see channel messages,
+ but may use channel commands.
+
+ Args:
+ subscriber (Object or Account): Subscriber to mute.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ mutelist=self.mutelist
+ ifsubscribernotinmutelist:
+ mutelist.append(subscriber)
+ self.db.mute_list=mutelist
+ returnTrue
+ returnFalse
+
+
[docs]defunmute(self,subscriber,**kwargs):
+ """
+ Removes an entity to the list of muted subscribers. A muted subscriber will no longer see channel messages,
+ but may use channel commands.
+
+ Args:
+ subscriber (Object or Account): The subscriber to unmute.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ mutelist=self.mutelist
+ ifsubscriberinmutelist:
+ mutelist.remove(subscriber)
+ self.db.mute_list=mutelist
+ returnTrue
+ returnFalse
+
+
[docs]defconnect(self,subscriber,**kwargs):
+ """
+ Connect the user to this channel. This checks access.
+
+ Args:
+ subscriber (Account or Object): the entity to subscribe
+ to this channel.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ success (bool): Whether or not the addition was
+ successful.
+
+ """
+ # check access
+ ifnotself.access(subscriber,"listen"):
+ returnFalse
+ # pre-join hook
+ connect=self.pre_join_channel(subscriber)
+ ifnotconnect:
+ returnFalse
+ # subscribe
+ self.subscriptions.add(subscriber)
+ # unmute
+ self.unmute(subscriber)
+ # post-join hook
+ self.post_join_channel(subscriber)
+ returnTrue
+
+
[docs]defdisconnect(self,subscriber,**kwargs):
+ """
+ Disconnect entity from this channel.
+
+ Args:
+ subscriber (Account of Object): the
+ entity to disconnect.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ success (bool): Whether or not the removal was
+ successful.
+
+ """
+ # pre-disconnect hook
+ disconnect=self.pre_leave_channel(subscriber)
+ ifnotdisconnect:
+ returnFalse
+ # disconnect
+ self.subscriptions.remove(subscriber)
+ # unmute
+ self.unmute(subscriber)
+ # post-disconnect hook
+ self.post_leave_channel(subscriber)
+ returnTrue
+
+
[docs]defaccess(
+ self,
+ accessing_obj,
+ access_type="listen",
+ default=False,
+ no_superuser_bypass=False,
+ **kwargs,
+ ):
+ """
+ Determines if another object has permission to access.
+
+ Args:
+ accessing_obj (Object): Object trying to access this one.
+ access_type (str, optional): Type of access sought.
+ default (bool, optional): What to return if no lock of access_type was found
+ no_superuser_bypass (bool, optional): Turns off superuser
+ lock bypass. Be careful with this one.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ return (bool): Result of lock check.
+
+ """
+ returnself.locks.check(
+ accessing_obj,
+ access_type=access_type,
+ default=default,
+ no_superuser_bypass=no_superuser_bypass,
+ )
+
+
[docs]@classmethod
+ defcreate(cls,key,account=None,*args,**kwargs):
+ """
+ Creates a basic Channel with default parameters, unless otherwise
+ specified or extended.
+
+ Provides a friendlier interface to the utils.create_channel() function.
+
+ Args:
+ key (str): This must be unique.
+ account (Account): Account to attribute this object to.
+
+ Keyword Args:
+ aliases (list of str): List of alternative (likely shorter) keynames.
+ description (str): A description of the channel, for use in listings.
+ locks (str): Lockstring.
+ keep_log (bool): Log channel throughput.
+ typeclass (str or class): The typeclass of the Channel (not
+ often used).
+ ip (str): IP address of creator (for object auditing).
+
+ Returns:
+ channel (Channel): A newly created Channel.
+ errors (list): A list of errors in string form, if any.
+
+ """
+ errors=[]
+ obj=None
+ ip=kwargs.pop("ip","")
+
+ try:
+ kwargs["desc"]=kwargs.pop("description","")
+ kwargs["typeclass"]=kwargs.get("typeclass",cls)
+ obj=create.create_channel(key,*args,**kwargs)
+
+ # Record creator id and creation IP
+ ifip:
+ obj.db.creator_ip=ip
+ ifaccount:
+ obj.db.creator_id=account.id
+
+ exceptExceptionasexc:
+ errors.append("An error occurred while creating this '%s' object."%key)
+ logger.log_err(exc)
+
+ returnobj,errors
+
+
[docs]defdelete(self):
+ """
+ Deletes channel while also cleaning up channelhandler.
+
+ """
+ self.attributes.clear()
+ self.aliases.clear()
+ super().delete()
+ fromevennia.comms.channelhandlerimportCHANNELHANDLER
+
+ CHANNELHANDLER.update()
+
+
[docs]defmessage_transform(
+ self,msgobj,emit=False,prefix=True,sender_strings=None,external=False,**kwargs
+ ):
+ """
+ Generates the formatted string sent to listeners on a channel.
+
+ Args:
+ msgobj (Msg): Message object to send.
+ emit (bool, optional): In emit mode the message is not associated
+ with a specific sender name.
+ prefix (bool, optional): Prefix `msg` with a text given by `self.channel_prefix`.
+ sender_strings (list, optional): Used by bots etc, one string per external sender.
+ external (bool, optional): If this is an external sender or not.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ ifsender_stringsorexternal:
+ body=self.format_external(msgobj,sender_strings,emit=emit)
+ else:
+ body=self.format_message(msgobj,emit=emit)
+ ifprefix:
+ body="%s%s"%(self.channel_prefix(msgobj,emit=emit),body)
+ msgobj.message=body
+ returnmsgobj
+
+
[docs]defdistribute_message(self,msgobj,online=False,**kwargs):
+ """
+ Method for grabbing all listeners that a message should be
+ sent to on this channel, and sending them a message.
+
+ Args:
+ msgobj (Msg or TempMsg): Message to distribute.
+ online (bool): Only send to receivers who are actually online
+ (not currently used):
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This is also where logging happens, if enabled.
+
+ """
+ # get all accounts or objects connected to this channel and send to them
+ ifonline:
+ subs=self.subscriptions.online()
+ else:
+ subs=self.subscriptions.all()
+ forentityinsubs:
+ # if the entity is muted, we don't send them a message
+ ifentityinself.mutelist:
+ continue
+ try:
+ # note our addition of the from_channel keyword here. This could be checked
+ # by a custom account.msg() to treat channel-receives differently.
+ entity.msg(
+ msgobj.message,from_obj=msgobj.senders,options={"from_channel":self.id}
+ )
+ exceptAttributeErrorase:
+ logger.log_trace("%s\nCannot send msg to '%s'."%(e,entity))
+
+ ifmsgobj.keep_log:
+ # log to file
+ logger.log_file(
+ msgobj.message,self.attributes.get("log_file")or"channel_%s.log"%self.key
+ )
+
+
[docs]defmsg(
+ self,
+ msgobj,
+ header=None,
+ senders=None,
+ sender_strings=None,
+ keep_log=None,
+ online=False,
+ emit=False,
+ external=False,
+ ):
+ """
+ Send the given message to all accounts connected to channel. Note that
+ no permission-checking is done here; it is assumed to have been
+ done before calling this method. The optional keywords are not used if
+ persistent is False.
+
+ Args:
+ msgobj (Msg, TempMsg or str): If a Msg/TempMsg, the remaining
+ keywords will be ignored (since the Msg/TempMsg object already
+ has all the data). If a string, this will either be sent as-is
+ (if persistent=False) or it will be used together with `header`
+ and `senders` keywords to create a Msg instance on the fly.
+ header (str, optional): A header for building the message.
+ senders (Object, Account or list, optional): Optional if persistent=False, used
+ to build senders for the message.
+ sender_strings (list, optional): Name strings of senders. Used for external
+ connections where the sender is not an account or object.
+ When this is defined, external will be assumed. The list will be
+ filtered so each sender-string only occurs once.
+ keep_log (bool or None, optional): This allows to temporarily change the logging status of
+ this channel message. If `None`, the Channel's `keep_log` Attribute will
+ be used. If `True` or `False`, that logging status will be used for this
+ message only (note that for unlogged channels, a `True` value here will
+ create a new log file only for this message).
+ online (bool, optional) - If this is set true, only messages people who are
+ online. Otherwise, messages all accounts connected. This can
+ make things faster, but may not trigger listeners on accounts
+ that are offline.
+ emit (bool, optional) - Signals to the message formatter that this message is
+ not to be directly associated with a name.
+ external (bool, optional): Treat this message as being
+ agnostic of its sender.
+
+ Returns:
+ success (bool): Returns `True` if message sending was
+ successful, `False` otherwise.
+
+ """
+ senders=make_iter(senders)ifsenderselse[]
+ ifisinstance(msgobj,str):
+ # given msgobj is a string - convert to msgobject (always TempMsg)
+ msgobj=TempMsg(senders=senders,header=header,message=msgobj,channels=[self])
+ # we store the logging setting for use in distribute_message()
+ msgobj.keep_log=keep_logifkeep_logisnotNoneelseself.db.keep_log
+
+ # start the sending
+ msgobj=self.pre_send_message(msgobj)
+ ifnotmsgobj:
+ returnFalse
+ ifsender_strings:
+ sender_strings=list(set(make_iter(sender_strings)))
+ msgobj=self.message_transform(
+ msgobj,emit=emit,sender_strings=sender_strings,external=external
+ )
+ self.distribute_message(msgobj,online=online)
+ self.post_send_message(msgobj)
+ returnTrue
+
+
[docs]deftempmsg(self,message,header=None,senders=None):
+ """
+ A wrapper for sending non-persistent messages.
+
+ Args:
+ message (str): Message to send.
+ header (str, optional): Header of message to send.
+ senders (Object or list, optional): Senders of message to send.
+
+ """
+ self.msg(message,senders=senders,header=header,keep_log=False)
+
+ # hooks
+
+
[docs]defchannel_prefix(self,msg=None,emit=False,**kwargs):
+ """
+ Hook method. How the channel should prefix itself for users.
+
+ Args:
+ msg (str, optional): Prefix text
+ emit (bool, optional): Switches to emit mode, which usually
+ means to not prefix the channel's info.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ prefix (str): The created channel prefix.
+
+ """
+ return""ifemitelse"[%s] "%self.key
+
+
[docs]defformat_senders(self,senders=None,**kwargs):
+ """
+ Hook method. Function used to format a list of sender names.
+
+ Args:
+ senders (list): Sender object names.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ formatted_list (str): The list of names formatted appropriately.
+
+ Notes:
+ This function exists separately so that external sources
+ can use it to format source names in the same manner as
+ normal object/account names.
+
+ """
+ ifnotsenders:
+ return""
+ return", ".join(senders)
+
+
[docs]defpose_transform(self,msgobj,sender_string,**kwargs):
+ """
+ Hook method. Detects if the sender is posing, and modifies the
+ message accordingly.
+
+ Args:
+ msgobj (Msg or TempMsg): The message to analyze for a pose.
+ sender_string (str): The name of the sender/poser.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ string (str): A message that combines the `sender_string`
+ component with `msg` in different ways depending on if a
+ pose was performed or not (this must be analyzed by the
+ hook).
+
+ """
+ pose=False
+ message=msgobj.message
+ message_start=message.lstrip()
+ ifmessage_start.startswith((":",";")):
+ pose=True
+ message=message[1:]
+ ifnotmessage.startswith((":","'",",")):
+ ifnotmessage.startswith(" "):
+ message=" "+message
+ ifpose:
+ return"%s%s"%(sender_string,message)
+ else:
+ return"%s: %s"%(sender_string,message)
+
+
[docs]defformat_external(self,msgobj,senders,emit=False,**kwargs):
+ """
+ Hook method. Used for formatting external messages. This is
+ needed as a separate operation because the senders of external
+ messages may not be in-game objects/accounts, and so cannot
+ have things like custom user preferences.
+
+ Args:
+ msgobj (Msg or TempMsg): The message to send.
+ senders (list): Strings, one per sender.
+ emit (bool, optional): A sender-agnostic message or not.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ transformed (str): A formatted string.
+
+ """
+ ifemitornotsenders:
+ returnmsgobj.message
+ senders=", ".join(senders)
+ returnself.pose_transform(msgobj,senders)
+
+
[docs]defformat_message(self,msgobj,emit=False,**kwargs):
+ """
+ Hook method. Formats a message body for display.
+
+ Args:
+ msgobj (Msg or TempMsg): The message object to send.
+ emit (bool, optional): The message is agnostic of senders.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ transformed (str): The formatted message.
+
+ """
+ # We don't want to count things like external sources as senders for
+ # the purpose of constructing the message string.
+ senders=[senderforsenderinmsgobj.sendersifhasattr(sender,"key")]
+ ifnotsenders:
+ emit=True
+ ifemit:
+ returnmsgobj.message
+ else:
+ senders=[sender.keyforsenderinmsgobj.senders]
+ senders=", ".join(senders)
+ returnself.pose_transform(msgobj,senders)
+
+
[docs]defpre_join_channel(self,joiner,**kwargs):
+ """
+ Hook method. Runs right before a channel is joined. If this
+ returns a false value, channel joining is aborted.
+
+ Args:
+ joiner (object): The joining object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ should_join (bool): If `False`, channel joining is aborted.
+
+ """
+ returnTrue
+
+
[docs]defpost_join_channel(self,joiner,**kwargs):
+ """
+ Hook method. Runs right after an object or account joins a channel.
+
+ Args:
+ joiner (object): The joining object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defpre_leave_channel(self,leaver,**kwargs):
+ """
+ Hook method. Runs right before a user leaves a channel. If this returns a false
+ value, leaving the channel will be aborted.
+
+ Args:
+ leaver (object): The leaving object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ should_leave (bool): If `False`, channel parting is aborted.
+
+ """
+ returnTrue
+
+
[docs]defpost_leave_channel(self,leaver,**kwargs):
+ """
+ Hook method. Runs right after an object or account leaves a channel.
+
+ Args:
+ leaver (object): The leaving object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defpre_send_message(self,msg,**kwargs):
+ """
+ Hook method. Runs before a message is sent to the channel and
+ should return the message object, after any transformations.
+ If the message is to be discarded, return a false value.
+
+ Args:
+ msg (Msg or TempMsg): Message to send.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ result (Msg, TempMsg or bool): If False, abort send.
+
+ """
+ returnmsg
+
+
[docs]defpost_send_message(self,msg,**kwargs):
+ """
+ Hook method. Run after a message is sent to the channel.
+
+ Args:
+ msg (Msg or TempMsg): Message sent.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_init(self):
+ """
+ Hook method. This is always called whenever this channel is
+ initiated -- that is, whenever it its typeclass is cached from
+ memory. This happens on-demand first time the channel is used
+ or activated in some way after being created but also after
+ each server restart or reload.
+
+ """
+ pass
+
+ #
+ # Web/Django methods
+ #
+
+
[docs]defweb_get_admin_url(self):
+ """
+ Returns the URI path for the Django Admin page for this object.
+
+ ex. Account#1 = '/admin/accounts/accountdb/1/change/'
+
+ Returns:
+ path (str): URI path to Django Admin page for object.
+
+ """
+ content_type=ContentType.objects.get_for_model(self.__class__)
+ returnreverse(
+ "admin:%s_%s_change"%(content_type.app_label,content_type.model),args=(self.id,)
+ )
+
+
[docs]@classmethod
+ defweb_get_create_url(cls):
+ """
+ Returns the URI path for a View that allows users to create new
+ instances of this object.
+
+ ex. Chargen = '/characters/create/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'channel-create' would be referenced by this method.
+
+ ex.
+ url(r'channels/create/', ChannelCreateView.as_view(), name='channel-create')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can create new objects is the
+ developer's responsibility.
+
+ Returns:
+ path (str): URI path to object creation page, if defined.
+
+ """
+ try:
+ returnreverse("%s-create"%slugify(cls._meta.verbose_name))
+ except:
+ return"#"
+
+
[docs]defweb_get_detail_url(self):
+ """
+ Returns the URI path for a View that allows users to view details for
+ this object.
+
+ ex. Oscar (Character) = '/characters/oscar/1/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'channel-detail' would be referenced by this method.
+
+ ex.
+ url(r'channels/(?P<slug>[\w\d\-]+)/$',
+ ChannelDetailView.as_view(), name='channel-detail')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can view this object is the developer's
+ responsibility.
+
+ Returns:
+ path (str): URI path to object detail page, if defined.
+
+ """
+ try:
+ returnreverse(
+ "%s-detail"%slugify(self._meta.verbose_name),
+ kwargs={"slug":slugify(self.db_key)},
+ )
+ except:
+ return"#"
+
+
[docs]defweb_get_update_url(self):
+ """
+ Returns the URI path for a View that allows users to update this
+ object.
+
+ ex. Oscar (Character) = '/characters/oscar/1/change/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'channel-update' would be referenced by this method.
+
+ ex.
+ url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
+ ChannelUpdateView.as_view(), name='channel-update')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can modify objects is the developer's
+ responsibility.
+
+ Returns:
+ path (str): URI path to object update page, if defined.
+
+ """
+ try:
+ returnreverse(
+ "%s-update"%slugify(self._meta.verbose_name),
+ kwargs={"slug":slugify(self.db_key)},
+ )
+ except:
+ return"#"
+
+
[docs]defweb_get_delete_url(self):
+ """
+ Returns the URI path for a View that allows users to delete this object.
+
+ ex. Oscar (Character) = '/characters/oscar/1/delete/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'channel-delete' would be referenced by this method.
+
+ ex.
+ url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
+ ChannelDeleteView.as_view(), name='channel-delete')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can delete this object is the developer's
+ responsibility.
+
+ Returns:
+ path (str): URI path to object deletion page, if defined.
+
+ """
+ try:
+ returnreverse(
+ "%s-delete"%slugify(self._meta.verbose_name),
+ kwargs={"slug":slugify(self.db_key)},
+ )
+ except:
+ return"#"
+
+ # Used by Django Sites/Admin
+ get_absolute_url=web_get_detail_url
+"""
+These managers define helper methods for accessing the database from
+Comm system components.
+
+"""
+
+
+fromdjango.db.modelsimportQ
+fromevennia.typeclasses.managersimportTypedObjectManager,TypeclassManager
+fromevennia.utilsimportlogger
+fromevennia.utils.utilsimportdbref
+
+_GA=object.__getattribute__
+_AccountDB=None
+_ObjectDB=None
+_ChannelDB=None
+_SESSIONS=None
+
+# error class
+
+
+
[docs]classCommError(Exception):
+ """
+ Raised by comm system, to allow feedback to player when caught.
+ """
+
+ pass
+
+
+#
+# helper functions
+#
+
+
+
[docs]defidentify_object(inp):
+ """
+ Helper function. Identifies if an object is an account or an object;
+ return its database model
+
+ Args:
+ inp (any): Entity to be idtified.
+
+ Returns:
+ identified (tuple): This is a tuple with (`inp`, identifier)
+ where `identifier` is one of "account", "object", "channel",
+ "string", "dbref" or None.
+
+ """
+ ifhasattr(inp,"__dbclass__"):
+ clsname=inp.__dbclass__.__name__
+ ifclsname=="AccountDB":
+ returninp,"account"
+ elifclsname=="ObjectDB":
+ returninp,"object"
+ elifclsname=="ChannelDB":
+ returninp,"channel"
+ ifisinstance(inp,str):
+ returninp,"string"
+ elifdbref(inp):
+ returndbref(inp),"dbref"
+ else:
+ returninp,None
+
+
+
[docs]defto_object(inp,objtype="account"):
+ """
+ Locates the object related to the given accountname or channel key.
+ If input was already the correct object, return it.
+
+ Args:
+ inp (any): The input object/string
+ objtype (str): Either 'account' or 'channel'.
+
+ Returns:
+ obj (object): The correct object related to `inp`.
+
+ """
+ obj,typ=identify_object(inp)
+ iftyp==objtype:
+ returnobj
+ ifobjtype=="account":
+ iftyp=="object":
+ returnobj.account
+ iftyp=="string":
+ return_AccountDB.objects.get(user_username__iexact=obj)
+ iftyp=="dbref":
+ return_AccountDB.objects.get(id=obj)
+ logger.log_err("%s%s%s%s%s"%(objtype,inp,obj,typ,type(inp)))
+ raiseCommError()
+ elifobjtype=="object":
+ iftyp=="account":
+ returnobj.obj
+ iftyp=="string":
+ return_ObjectDB.objects.get(db_key__iexact=obj)
+ iftyp=="dbref":
+ return_ObjectDB.objects.get(id=obj)
+ logger.log_err("%s%s%s%s%s"%(objtype,inp,obj,typ,type(inp)))
+ raiseCommError()
+ elifobjtype=="channel":
+ iftyp=="string":
+ return_ChannelDB.objects.get(db_key__iexact=obj)
+ iftyp=="dbref":
+ return_ChannelDB.objects.get(id=obj)
+ logger.log_err("%s%s%s%s%s"%(objtype,inp,obj,typ,type(inp)))
+ raiseCommError()
+ # an unknown
+ returnNone
+
+
+#
+# Msg manager
+#
+
+
+
[docs]classMsgManager(TypedObjectManager):
+ """
+ This MsgManager implements methods for searching and manipulating
+ Messages directly from the database.
+
+ These methods will all return database objects (or QuerySets)
+ directly.
+
+ A Message represents one unit of communication, be it over a
+ Channel or via some form of in-game mail system. Like an e-mail,
+ it always has a sender and can have any number of receivers (some
+ of which may be Channels).
+
+ """
+
+
[docs]defidentify_object(self,inp):
+ """
+ Wrapper to identify_object if accessing via the manager directly.
+
+ Args:
+ inp (any): Entity to be idtified.
+
+ Returns:
+ identified (tuple): This is a tuple with (`inp`, identifier)
+ where `identifier` is one of "account", "object", "channel",
+ "string", "dbref" or None.
+
+ """
+ returnidentify_object(inp)
+
+
[docs]defget_message_by_id(self,idnum):
+ """
+ Retrieve message by its id.
+
+ Args:
+ idnum (int or str): The dbref to retrieve.
+
+ Returns:
+ message (Msg): The message.
+
+ """
+ try:
+ returnself.get(id=self.dbref(idnum,reqhash=False))
+ exceptException:
+ returnNone
+
+
[docs]defget_messages_by_sender(self,sender,exclude_channel_messages=False):
+ """
+ Get all messages sent by one entity - this could be either a
+ account or an object
+
+ Args:
+ sender (Account or Object): The sender of the message.
+ exclude_channel_messages (bool, optional): Only return messages
+ not aimed at a channel (that is, private tells for example)
+
+ Returns:
+ messages (list): List of matching messages
+
+ Raises:
+ CommError: For incorrect sender types.
+
+ """
+ obj,typ=identify_object(sender)
+ ifexclude_channel_messages:
+ # explicitly exclude channel recipients
+ iftyp=="account":
+ returnlist(
+ self.filter(db_sender_accounts=obj,db_receivers_channels__isnull=True).exclude(
+ db_hide_from_accounts=obj
+ )
+ )
+ eliftyp=="object":
+ returnlist(
+ self.filter(db_sender_objects=obj,db_receivers_channels__isnull=True).exclude(
+ db_hide_from_objects=obj
+ )
+ )
+ else:
+ raiseCommError
+ else:
+ # get everything, channel or not
+ iftyp=="account":
+ returnlist(self.filter(db_sender_accounts=obj).exclude(db_hide_from_accounts=obj))
+ eliftyp=="object":
+ returnlist(self.filter(db_sender_objects=obj).exclude(db_hide_from_objects=obj))
+ else:
+ raiseCommError
+
+
[docs]defget_messages_by_receiver(self,recipient):
+ """
+ Get all messages sent to one given recipient.
+
+ Args:
+ recipient (Object, Account or Channel): The recipient of the messages to search for.
+
+ Returns:
+ messages (list): Matching messages.
+
+ Raises:
+ CommError: If the `recipient` is not of a valid type.
+
+ """
+ obj,typ=identify_object(recipient)
+ iftyp=="account":
+ returnlist(self.filter(db_receivers_accounts=obj).exclude(db_hide_from_accounts=obj))
+ eliftyp=="object":
+ returnlist(self.filter(db_receivers_objects=obj).exclude(db_hide_from_objects=obj))
+ eliftyp=="channel":
+ returnlist(self.filter(db_receivers_channels=obj).exclude(db_hide_from_channels=obj))
+ else:
+ raiseCommError
+
+
[docs]defget_messages_by_channel(self,channel):
+ """
+ Get all persistent messages sent to one channel.
+
+ Args:
+ channel (Channel): The channel to find messages for.
+
+ Returns:
+ messages (list): Persistent Msg objects saved for this channel.
+
+ """
+ returnself.filter(db_receivers_channels=channel).exclude(db_hide_from_channels=channel)
+
+
[docs]defsearch_message(self,sender=None,receiver=None,freetext=None,dbref=None):
+ """
+ Search the message database for particular messages. At least
+ one of the arguments must be given to do a search.
+
+ Args:
+ sender (Object or Account, optional): Get messages sent by a particular account or object
+ receiver (Object, Account or Channel, optional): Get messages
+ received by a certain account,object or channel
+ freetext (str): Search for a text string in a message. NOTE:
+ This can potentially be slow, so make sure to supply one of
+ the other arguments to limit the search.
+ dbref (int): The exact database id of the message. This will override
+ all other search criteria since it's unique and
+ always gives only one match.
+
+ Returns:
+ messages (list or Msg): A list of message matches or a single match if `dbref` was given.
+
+ """
+ # unique msg id
+ ifdbref:
+ msg=self.objects.filter(id=dbref)
+ ifmsg:
+ returnmsg[0]
+
+ # We use Q objects to gradually build up the query - this way we only
+ # need to do one database lookup at the end rather than gradually
+ # refining with multiple filter:s. Django Note: Q objects can be
+ # combined with & and | (=AND,OR). ~ negates the queryset
+
+ # filter by sender
+ sender,styp=identify_object(sender)
+ ifstyp=="account":
+ sender_restrict=Q(db_sender_accounts=sender)&~Q(db_hide_from_accounts=sender)
+ elifstyp=="object":
+ sender_restrict=Q(db_sender_objects=sender)&~Q(db_hide_from_objects=sender)
+ else:
+ sender_restrict=Q()
+ # filter by receiver
+ receiver,rtyp=identify_object(receiver)
+ ifrtyp=="account":
+ receiver_restrict=Q(db_receivers_accounts=receiver)&~Q(
+ db_hide_from_accounts=receiver
+ )
+ elifrtyp=="object":
+ receiver_restrict=Q(db_receivers_objects=receiver)&~Q(db_hide_from_objects=receiver)
+ elifrtyp=="channel":
+ receiver_restrict=Q(db_receivers_channels=receiver)&~Q(
+ db_hide_from_channels=receiver
+ )
+ else:
+ receiver_restrict=Q()
+ # filter by full text
+ iffreetext:
+ fulltext_restrict=Q(db_header__icontains=freetext)|Q(db_message__icontains=freetext)
+ else:
+ fulltext_restrict=Q()
+ # execute the query
+ returnlist(self.filter(sender_restrict&receiver_restrict&fulltext_restrict))
+
+ # back-compatibility alias
+ message_search=search_message
+
+
+#
+# Channel manager
+#
+
+
+
[docs]classChannelDBManager(TypedObjectManager):
+ """
+ This ChannelManager implements methods for searching and
+ manipulating Channels directly from the database.
+
+ These methods will all return database objects (or QuerySets)
+ directly.
+
+ A Channel is an in-game venue for communication. It's essentially
+ representation of a re-sender: Users sends Messages to the
+ Channel, and the Channel re-sends those messages to all users
+ subscribed to the Channel.
+
+ """
+
+
[docs]defget_all_channels(self):
+ """
+ Get all channels.
+
+ Returns:
+ channels (list): All channels in game.
+
+ """
+ returnself.all()
+
+
[docs]defget_channel(self,channelkey):
+ """
+ Return the channel object if given its key.
+ Also searches its aliases.
+
+ Args:
+ channelkey (str): Channel key to search for.
+
+ Returns:
+ channel (Channel or None): A channel match.
+
+ """
+ dbref=self.dbref(channelkey)
+ ifdbref:
+ try:
+ returnself.get(id=dbref)
+ exceptself.model.DoesNotExist:
+ pass
+ results=self.filter(
+ Q(db_key__iexact=channelkey)
+ |Q(db_tags__db_tagtype__iexact="alias",db_tags__db_key__iexact=channelkey)
+ ).distinct()
+ returnresults[0]ifresultselseNone
+
+
[docs]defget_subscriptions(self,subscriber):
+ """
+ Return all channels a given entity is subscribed to.
+
+ Args:
+ subscriber (Object or Account): The one subscribing.
+
+ Returns:
+ subscriptions (list): Channel subscribed to.
+
+ """
+ clsname=subscriber.__dbclass__.__name__
+ ifclsname=="AccountDB":
+ returnsubscriber.account_subscription_set.all()
+ ifclsname=="ObjectDB":
+ returnsubscriber.object_subscription_set.all()
+ return[]
+
+
[docs]defsearch_channel(self,ostring,exact=True):
+ """
+ Search the channel database for a particular channel.
+
+ Args:
+ ostring (str): The key or database id of the channel.
+ exact (bool, optional): Require an exact (but not
+ case sensitive) match.
+
+ """
+ dbref=self.dbref(ostring)
+ ifdbref:
+ try:
+ returnself.get(id=dbref)
+ exceptself.model.DoesNotExist:
+ pass
+ ifexact:
+ channels=self.filter(
+ Q(db_key__iexact=ostring)
+ |Q(db_tags__db_tagtype__iexact="alias",db_tags__db_key__iexact=ostring)
+ ).distinct()
+ else:
+ channels=self.filter(
+ Q(db_key__icontains=ostring)
+ |Q(db_tags__db_tagtype__iexact="alias",db_tags__db_key__icontains=ostring)
+ ).distinct()
+ returnchannels
+
+ # back-compatibility alias
+ channel_search=search_channel
+
+
+
[docs]classChannelManager(ChannelDBManager,TypeclassManager):
+ """
+ Wrapper to group the typeclass manager to a consistent name.
+ """
+
+ pass
+"""
+Models for the in-game communication system.
+
+The comm system could take the form of channels, but can also be
+adopted for storing tells or in-game mail.
+
+The comsystem's main component is the Message (Msg), which carries the
+actual information between two parties. Msgs are stored in the
+database and usually not deleted. A Msg always have one sender (a
+user), but can have any number targets, both users and channels.
+
+For non-persistent (and slightly faster) use one can also use the
+TempMsg, which mimics the Msg API but without actually saving to the
+database.
+
+Channels are central objects that act as targets for Msgs. Accounts can
+connect to channels by use of a ChannelConnect object (this object is
+necessary to easily be able to delete connections on the fly).
+"""
+fromdjango.confimportsettings
+fromdjango.utilsimporttimezone
+fromdjango.dbimportmodels
+fromevennia.typeclasses.modelsimportTypedObject
+fromevennia.typeclasses.tagsimportTag,TagHandler
+fromevennia.utils.idmapper.modelsimportSharedMemoryModel
+fromevennia.commsimportmanagers
+fromevennia.locks.lockhandlerimportLockHandler
+fromevennia.utils.utilsimportcrop,make_iter,lazy_property
+
+__all__=("Msg","TempMsg","ChannelDB")
+
+
+_GA=object.__getattribute__
+_SA=object.__setattr__
+_DA=object.__delattr__
+
+_CHANNELHANDLER=None
+
+
+# ------------------------------------------------------------
+#
+# Msg
+#
+# ------------------------------------------------------------
+
+
+
[docs]classMsg(SharedMemoryModel):
+ """
+ A single message. This model describes all ooc messages
+ sent in-game, both to channels and between accounts.
+
+ The Msg class defines the following database fields (all
+ accessed via specific handler methods):
+
+ - db_sender_accounts: Account senders
+ - db_sender_objects: Object senders
+ - db_sender_scripts: Script senders
+ - db_sender_external: External senders (defined as string names)
+ - db_receivers_accounts: Receiving accounts
+ - db_receivers_objects: Receiving objects
+ - db_receivers_scripts: Receiveing scripts
+ - db_receivers_channels: Receiving channels
+ - db_header: Header text
+ - db_message: The actual message text
+ - db_date_created: time message was created / sent
+ - db_hide_from_sender: bool if message should be hidden from sender
+ - db_hide_from_receivers: list of receiver objects to hide message from
+ - db_hide_from_channels: list of channels objects to hide message from
+ - db_lock_storage: Internal storage of lock strings.
+
+ """
+
+ #
+ # Msg database model setup
+ #
+ #
+ # These databse fields are all set using their corresponding properties,
+ # named same as the field, but withtout the db_* prefix.
+
+ # Sender is either an account, an object or an external sender, like
+ # an IRC channel; normally there is only one, but if co-modification of
+ # a message is allowed, there may be more than one "author"
+ db_sender_accounts=models.ManyToManyField(
+ "accounts.AccountDB",
+ related_name="sender_account_set",
+ blank=True,
+ verbose_name="sender(account)",
+ db_index=True,
+ )
+
+ db_sender_objects=models.ManyToManyField(
+ "objects.ObjectDB",
+ related_name="sender_object_set",
+ blank=True,
+ verbose_name="sender(object)",
+ db_index=True,
+ )
+ db_sender_scripts=models.ManyToManyField(
+ "scripts.ScriptDB",
+ related_name="sender_script_set",
+ blank=True,
+ verbose_name="sender(script)",
+ db_index=True,
+ )
+ db_sender_external=models.CharField(
+ "external sender",
+ max_length=255,
+ null=True,
+ blank=True,
+ db_index=True,
+ help_text="identifier for external sender, for example a sender over an "
+ "IRC connection (i.e. someone who doesn't have an exixtence in-game).",
+ )
+ # The destination objects of this message. Stored as a
+ # comma-separated string of object dbrefs. Can be defined along
+ # with channels below.
+ db_receivers_accounts=models.ManyToManyField(
+ "accounts.AccountDB",
+ related_name="receiver_account_set",
+ blank=True,
+ help_text="account receivers",
+ )
+
+ db_receivers_objects=models.ManyToManyField(
+ "objects.ObjectDB",
+ related_name="receiver_object_set",
+ blank=True,
+ help_text="object receivers",
+ )
+ db_receivers_scripts=models.ManyToManyField(
+ "scripts.ScriptDB",
+ related_name="receiver_script_set",
+ blank=True,
+ help_text="script_receivers",
+ )
+ db_receivers_channels=models.ManyToManyField(
+ "ChannelDB",related_name="channel_set",blank=True,help_text="channel recievers"
+ )
+
+ # header could be used for meta-info about the message if your system needs
+ # it, or as a separate store for the mail subject line maybe.
+ db_header=models.TextField("header",null=True,blank=True)
+ # the message body itself
+ db_message=models.TextField("message")
+ # send date
+ db_date_created=models.DateTimeField(
+ "date sent",editable=False,auto_now_add=True,db_index=True
+ )
+ # lock storage
+ db_lock_storage=models.TextField(
+ "locks",blank=True,help_text="access locks on this message."
+ )
+
+ # these can be used to filter/hide a given message from supplied objects/accounts/channels
+ db_hide_from_accounts=models.ManyToManyField(
+ "accounts.AccountDB",related_name="hide_from_accounts_set",blank=True
+ )
+
+ db_hide_from_objects=models.ManyToManyField(
+ "objects.ObjectDB",related_name="hide_from_objects_set",blank=True
+ )
+ db_hide_from_channels=models.ManyToManyField(
+ "ChannelDB",related_name="hide_from_channels_set",blank=True
+ )
+
+ db_tags=models.ManyToManyField(
+ Tag,
+ blank=True,
+ help_text="tags on this message. Tags are simple string markers to identify, group and alias messages.",
+ )
+
+ # Database manager
+ objects=managers.MsgManager()
+ _is_deleted=False
+
+
+
+ # Wrapper properties to easily set database fields. These are
+ # @property decorators that allows to access these fields using
+ # normal python operations (without having to remember to save()
+ # etc). So e.g. a property 'attr' has a get/set/del decorator
+ # defined that allows the user to do self.attr = value,
+ # value = self.attr and del self.attr respectively (where self
+ # is the object in question).
+
+ # sender property (wraps db_sender_*)
+ # @property
+ def__senders_get(self):
+ "Getter. Allows for value = self.sender"
+ return(
+ list(self.db_sender_accounts.all())
+ +list(self.db_sender_objects.all())
+ +list(self.db_sender_scripts.all())
+ +self.extra_senders
+ )
+
+ # @sender.setter
+ def__senders_set(self,senders):
+ "Setter. Allows for self.sender = value"
+ forsenderinmake_iter(senders):
+ ifnotsender:
+ continue
+ ifisinstance(sender,str):
+ self.db_sender_external=sender
+ self.extra_senders.append(sender)
+ self.save(update_fields=["db_sender_external"])
+ continue
+ ifnothasattr(sender,"__dbclass__"):
+ raiseValueError("This is a not a typeclassed object!")
+ clsname=sender.__dbclass__.__name__
+ ifclsname=="ObjectDB":
+ self.db_sender_objects.add(sender)
+ elifclsname=="AccountDB":
+ self.db_sender_accounts.add(sender)
+ elifclsname=="ScriptDB":
+ self.db_sender_scripts.add(sender)
+
+ # @sender.deleter
+ def__senders_del(self):
+ "Deleter. Clears all senders"
+ self.db_sender_accounts.clear()
+ self.db_sender_objects.clear()
+ self.db_sender_scripts.clear()
+ self.db_sender_external=""
+ self.extra_senders=[]
+ self.save()
+
+ senders=property(__senders_get,__senders_set,__senders_del)
+
+
[docs]defremove_sender(self,senders):
+ """
+ Remove a single sender or a list of senders.
+
+ Args:
+ senders (Account, Object, str or list): Senders to remove.
+
+ """
+ forsenderinmake_iter(senders):
+ ifnotsender:
+ continue
+ ifisinstance(sender,str):
+ self.db_sender_external=""
+ self.save(update_fields=["db_sender_external"])
+ ifnothasattr(sender,"__dbclass__"):
+ raiseValueError("This is a not a typeclassed object!")
+ clsname=sender.__dbclass__.__name__
+ ifclsname=="ObjectDB":
+ self.db_sender_objects.remove(sender)
+ elifclsname=="AccountDB":
+ self.db_sender_accounts.remove(sender)
+ elifclsname=="ScriptDB":
+ self.db_sender_accounts.remove(sender)
+
+ # receivers property
+ # @property
+ def__receivers_get(self):
+ """
+ Getter. Allows for value = self.receivers.
+ Returns four lists of receivers: accounts, objects, scripts and channels.
+ """
+ return(
+ list(self.db_receivers_accounts.all())
+ +list(self.db_receivers_objects.all())
+ +list(self.db_receivers_scripts.all())
+ +list(self.db_receivers_channels.all())
+ )
+
+ # @receivers.setter
+ def__receivers_set(self,receivers):
+ """
+ Setter. Allows for self.receivers = value.
+ This appends a new receiver to the message.
+ """
+ forreceiverinmake_iter(receivers):
+ ifnotreceiver:
+ continue
+ ifnothasattr(receiver,"__dbclass__"):
+ raiseValueError("This is a not a typeclassed object!")
+ clsname=receiver.__dbclass__.__name__
+ ifclsname=="ObjectDB":
+ self.db_receivers_objects.add(receiver)
+ elifclsname=="AccountDB":
+ self.db_receivers_accounts.add(receiver)
+ elifclsname=="ScriptDB":
+ self.db_receivers_scripts.add(receiver)
+ elifclsname=="ChannelDB":
+ self.db_receivers_channels.add(receiver)
+
+ # @receivers.deleter
+ def__receivers_del(self):
+ "Deleter. Clears all receivers"
+ self.db_receivers_accounts.clear()
+ self.db_receivers_objects.clear()
+ self.db_receivers_scripts.clear()
+ self.db_receivers_channels.clear()
+ self.save()
+
+ receivers=property(__receivers_get,__receivers_set,__receivers_del)
+
+
[docs]defremove_receiver(self,receivers):
+ """
+ Remove a single receiver or a list of receivers.
+
+ Args:
+ receivers (Account, Object, Script, Channel or list): Receiver to remove.
+
+ """
+ forreceiverinmake_iter(receivers):
+ ifnotreceiver:
+ continue
+ ifnothasattr(receiver,"__dbclass__"):
+ raiseValueError("This is a not a typeclassed object!")
+ clsname=receiver.__dbclass__.__name__
+ ifclsname=="ObjectDB":
+ self.db_receivers_objects.remove(receiver)
+ elifclsname=="AccountDB":
+ self.db_receivers_accounts.remove(receiver)
+ elifclsname=="ScriptDB":
+ self.db_receivers_scripts.remove(receiver)
+ elifclsname=="ChannelDB":
+ self.db_receivers_channels.remove(receiver)
+
+ # channels property
+ # @property
+ def__channels_get(self):
+ "Getter. Allows for value = self.channels. Returns a list of channels."
+ returnself.db_receivers_channels.all()
+
+ # @channels.setter
+ def__channels_set(self,value):
+ """
+ Setter. Allows for self.channels = value.
+ Requires a channel to be added.
+ """
+ forvalin(vforvinmake_iter(value)ifv):
+ self.db_receivers_channels.add(val)
+
+ # @channels.deleter
+ def__channels_del(self):
+ "Deleter. Allows for del self.channels"
+ self.db_receivers_channels.clear()
+ self.save()
+
+ channels=property(__channels_get,__channels_set,__channels_del)
+
+ def__hide_from_get(self):
+ """
+ Getter. Allows for value = self.hide_from.
+ Returns 3 lists of accounts, objects and channels
+ """
+ return(
+ self.db_hide_from_accounts.all(),
+ self.db_hide_from_objects.all(),
+ self.db_hide_from_channels.all(),
+ )
+
+ # @hide_from_sender.setter
+ def__hide_from_set(self,hiders):
+ "Setter. Allows for self.hide_from = value. Will append to hiders"
+ forhiderinmake_iter(hiders):
+ ifnothider:
+ continue
+ ifnothasattr(hider,"__dbclass__"):
+ raiseValueError("This is a not a typeclassed object!")
+ clsname=hider.__dbclass__.__name__
+ ifclsname=="AccountDB":
+ self.db_hide_from_accounts.add(hider.__dbclass__)
+ elifclsname=="ObjectDB":
+ self.db_hide_from_objects.add(hider.__dbclass__)
+ elifclsname=="ChannelDB":
+ self.db_hide_from_channels.add(hider.__dbclass__)
+
+ # @hide_from_sender.deleter
+ def__hide_from_del(self):
+ "Deleter. Allows for del self.hide_from_senders"
+ self.db_hide_from_accounts.clear()
+ self.db_hide_from_objects.clear()
+ self.db_hide_from_channels.clear()
+ self.save()
+
+ hide_from=property(__hide_from_get,__hide_from_set,__hide_from_del)
+
+ #
+ # Msg class methods
+ #
+
+ def__str__(self):
+ "This handles what is shown when e.g. printing the message"
+ senders=",".join(getattr(obj,"key",str(obj))forobjinself.senders)
+
+ receivers=",".join(
+ ["[%s]"%getattr(obj,"key",str(obj))forobjinself.channels]
+ +[getattr(obj,"key",str(obj))forobjinself.receivers]
+ )
+ return"%s->%s: %s"%(senders,receivers,crop(self.message,width=40))
+
+
[docs]defaccess(self,accessing_obj,access_type="read",default=False):
+ """
+ Checks lock access.
+
+ Args:
+ accessing_obj (Object or Account): The object trying to gain access.
+ access_type (str, optional): The type of lock access to check.
+ default (bool): Fallback to use if `access_type` lock is not defined.
+
+ Returns:
+ result (bool): If access was granted or not.
+
+ """
+ returnself.locks.check(accessing_obj,access_type=access_type,default=default)
[docs]classTempMsg(object):
+ """
+ This is a non-persistent object for sending temporary messages
+ that will not be stored. It mimics the "real" Msg object, but
+ doesn't require sender to be given.
+
+ """
+
+
[docs]def__init__(
+ self,
+ senders=None,
+ receivers=None,
+ channels=None,
+ message="",
+ header="",
+ type="",
+ lockstring="",
+ hide_from=None,
+ ):
+ """
+ Creates the temp message.
+
+ Args:
+ senders (any or list, optional): Senders of the message.
+ receivers (Account, Object, Channel or list, optional): Receivers of this message.
+ channels (Channel or list, optional): Channels to send to.
+ message (str, optional): Message to send.
+ header (str, optional): Header of message.
+ type (str, optional): Message class, if any.
+ lockstring (str, optional): Lock for the message.
+ hide_from (Account, Object, Channel or list, optional): Entities to hide this message from.
+
+ """
+ self.senders=sendersandmake_iter(senders)or[]
+ self.receivers=receiversandmake_iter(receivers)or[]
+ self.channels=channelsandmake_iter(channels)or[]
+ self.type=type
+ self.header=header
+ self.message=message
+ self.lock_storage=lockstring
+ self.hide_from=hide_fromandmake_iter(hide_from)or[]
+ self.date_created=timezone.now()
+
+ def__str__(self):
+ """
+ This handles what is shown when e.g. printing the message.
+ """
+ senders=",".join(obj.keyforobjinself.senders)
+ receivers=",".join(
+ ["[%s]"%obj.keyforobjinself.channels]+[obj.keyforobjinself.receivers]
+ )
+ return"%s->%s: %s"%(senders,receivers,crop(self.message,width=40))
+
+
[docs]defremove_sender(self,sender):
+ """
+ Remove a sender or a list of senders.
+
+ Args:
+ sender (Object, Account, str or list): Senders to remove.
+
+ """
+ foroinmake_iter(sender):
+ try:
+ self.senders.remove(o)
+ exceptValueError:
+ pass# nothing to remove
+
+
[docs]defremove_receiver(self,receiver):
+ """
+ Remove a receiver or a list of receivers
+
+ Args:
+ receiver (Object, Account, Channel, str or list): Receivers to remove.
+ """
+
+ foroinmake_iter(receiver):
+ try:
+ self.senders.remove(o)
+ exceptValueError:
+ pass# nothing to remove
+
+
[docs]defaccess(self,accessing_obj,access_type="read",default=False):
+ """
+ Checks lock access.
+
+ Args:
+ accessing_obj (Object or Account): The object trying to gain access.
+ access_type (str, optional): The type of lock access to check.
+ default (bool): Fallback to use if `access_type` lock is not defined.
+
+ Returns:
+ result (bool): If access was granted or not.
+
+ """
+ returnself.locks.check(accessing_obj,access_type=access_type,default=default)
+
+
+# ------------------------------------------------------------
+#
+# Channel
+#
+# ------------------------------------------------------------
+
+
+classSubscriptionHandler(object):
+ """
+ This handler manages subscriptions to the
+ channel and hides away which type of entity is
+ subscribing (Account or Object)
+ """
+
+ def__init__(self,obj):
+ """
+ Initialize the handler
+
+ Attr:
+ obj (ChannelDB): The channel the handler sits on.
+
+ """
+ self.obj=obj
+ self._cache=None
+
+ def_recache(self):
+ self._cache={
+ account:True
+ foraccountinself.obj.db_account_subscriptions.all()
+ ifhasattr(account,"pk")andaccount.pk
+ }
+ self._cache.update(
+ {
+ obj:True
+ forobjinself.obj.db_object_subscriptions.all()
+ ifhasattr(obj,"pk")andobj.pk
+ }
+ )
+
+ defhas(self,entity):
+ """
+ Check if the given entity subscribe to this channel
+
+ Args:
+ entity (str, Account or Object): The entity to return. If
+ a string, it assumed to be the key or the #dbref
+ of the entity.
+
+ Returns:
+ subscriber (Account, Object or None): The given
+ subscriber.
+
+ """
+ ifself._cacheisNone:
+ self._recache()
+ returnentityinself._cache
+
+ defadd(self,entity):
+ """
+ Subscribe an entity to this channel.
+
+ Args:
+ entity (Account, Object or list): The entity or
+ list of entities to subscribe to this channel.
+
+ Note:
+ No access-checking is done here, this must have
+ been done before calling this method. Also
+ no hooks will be called.
+
+ """
+ global_CHANNELHANDLER
+ ifnot_CHANNELHANDLER:
+ fromevennia.comms.channelhandlerimportCHANNEL_HANDLERas_CHANNELHANDLER
+ forsubscriberinmake_iter(entity):
+ ifsubscriber:
+ clsname=subscriber.__dbclass__.__name__
+ # chooses the right type
+ ifclsname=="ObjectDB":
+ self.obj.db_object_subscriptions.add(subscriber)
+ elifclsname=="AccountDB":
+ self.obj.db_account_subscriptions.add(subscriber)
+ _CHANNELHANDLER._cached_cmdsets.pop(subscriber,None)
+ self._recache()
+
+ defremove(self,entity):
+ """
+ Remove a subscriber from the channel.
+
+ Args:
+ entity (Account, Object or list): The entity or
+ entities to un-subscribe from the channel.
+
+ """
+ global_CHANNELHANDLER
+ ifnot_CHANNELHANDLER:
+ fromevennia.comms.channelhandlerimportCHANNEL_HANDLERas_CHANNELHANDLER
+ forsubscriberinmake_iter(entity):
+ ifsubscriber:
+ clsname=subscriber.__dbclass__.__name__
+ # chooses the right type
+ ifclsname=="AccountDB":
+ self.obj.db_account_subscriptions.remove(entity)
+ elifclsname=="ObjectDB":
+ self.obj.db_object_subscriptions.remove(entity)
+ _CHANNELHANDLER._cached_cmdsets.pop(subscriber,None)
+ self._recache()
+
+ defall(self):
+ """
+ Get all subscriptions to this channel.
+
+ Returns:
+ subscribers (list): The subscribers. This
+ may be a mix of Accounts and Objects!
+
+ """
+ ifself._cacheisNone:
+ self._recache()
+ returnself._cache
+
+ get=all# alias
+
+ defonline(self):
+ """
+ Get all online accounts from our cache
+ Returns:
+ subscribers (list): Subscribers who are online or
+ are puppeted by an online account.
+ """
+ subs=[]
+ recache_needed=False
+ forobjinself.all():
+ fromdjango.core.exceptionsimportObjectDoesNotExist
+
+ try:
+ ifhasattr(obj,"account")andobj.account:
+ obj=obj.account
+ ifnotobj.is_connected:
+ continue
+ exceptObjectDoesNotExist:
+ # a subscribed object has already been deleted. Mark that we need a recache and ignore it
+ recache_needed=True
+ continue
+ subs.append(obj)
+ ifrecache_needed:
+ self._recache()
+ returnsubs
+
+ defclear(self):
+ """
+ Remove all subscribers from channel.
+
+ """
+ self.obj.db_account_subscriptions.clear()
+ self.obj.db_object_subscriptions.clear()
+ self._cache=None
+
+
+
[docs]classChannelDB(TypedObject):
+ """
+ This is the basis of a comm channel, only implementing
+ the very basics of distributing messages.
+
+ The Channel class defines the following database fields
+ beyond the ones inherited from TypedObject:
+
+ - db_account_subscriptions: The Account subscriptions.
+ - db_object_subscriptions: The Object subscriptions.
+
+ """
+
+ db_account_subscriptions=models.ManyToManyField(
+ "accounts.AccountDB",
+ related_name="account_subscription_set",
+ blank=True,
+ verbose_name="account subscriptions",
+ db_index=True,
+ )
+
+ db_object_subscriptions=models.ManyToManyField(
+ "objects.ObjectDB",
+ related_name="object_subscription_set",
+ blank=True,
+ verbose_name="object subscriptions",
+ db_index=True,
+ )
+
+ # Database manager
+ objects=managers.ChannelDBManager()
+
+ __settingclasspath__=settings.BASE_CHANNEL_TYPECLASS
+ __defaultclasspath__="evennia.comms.comms.DefaultChannel"
+ __applabel__="comms"
+
+ classMeta(object):
+ "Define Django meta options"
+ verbose_name="Channel"
+ verbose_name_plural="Channels"
+
+ def__str__(self):
+ "Echoes the text representation of the channel."
+ return"Channel '%s' (%s)"%(self.key,self.db.desc)
+
+
+"""
+Barter system
+
+Evennia contribution - Griatch 2012
+
+
+This implements a full barter system - a way for players to safely
+trade items between each other using code rather than simple free-form
+talking. The advantage of this is increased buy/sell safety but it
+also streamlines the process and makes it faster when doing many
+transactions (since goods are automatically exchanged once both
+agree).
+
+This system is primarily intended for a barter economy, but can easily
+be used in a monetary economy as well -- just let the "goods" on one
+side be coin objects (this is more flexible than a simple "buy"
+command since you can mix coins and goods in your trade).
+
+In this module, a "barter" is generally referred to as a "trade".
+
+
+- Trade example
+
+A trade (barter) action works like this: A and B are the parties.
+
+1) opening a trade
+
+A: trade B: Hi, I have a nice extra sword. You wanna trade?
+B sees: A says: "Hi, I have a nice extra sword. You wanna trade?"
+ A wants to trade with you. Enter 'trade A <emote>' to accept.
+B: trade A: Hm, I could use a good sword ...
+A sees: B says: "Hm, I could use a good sword ...
+ B accepts the trade. Use 'trade help' for aid.
+B sees: You are now trading with A. Use 'trade help' for aid.
+
+2) negotiating
+
+A: offer sword: This is a nice sword. I would need some rations in trade.
+B sees: A says: "This is a nice sword. I would need some rations in trade."
+ [A offers Sword of might.]
+B evaluate sword
+B sees: <Sword's description and possibly stats>
+B: offer ration: This is a prime ration.
+A sees: B says: "This is a prime ration."
+ [B offers iron ration]
+A: say Hey, this is a nice sword, I need something more for it.
+B sees: A says: "Hey this is a nice sword, I need something more for it."
+B: offer sword,apple: Alright. I will also include a magic apple. That's my last offer.
+A sees: B says: "Alright, I will also include a magic apple. That's my last offer."
+ [B offers iron ration and magic apple]
+A accept: You are killing me here, but alright.
+B sees: A says: "You are killing me here, but alright."
+ [A accepts your offer. You must now also accept.]
+B accept: Good, nice making business with you.
+ You accept the deal. Deal is made and goods changed hands.
+A sees: B says: "Good, nice making business with you."
+ B accepts the deal. Deal is made and goods changed hands.
+
+At this point the trading system is exited and the negotiated items
+are automatically exchanged between the parties. In this example B was
+the only one changing their offer, but also A could have changed their
+offer until the two parties found something they could agree on. The
+emotes are optional but useful for RP-heavy worlds.
+
+- Technical info
+
+The trade is implemented by use of a TradeHandler. This object is a
+common place for storing the current status of negotiations. It is
+created on the object initiating the trade, and also stored on the
+other party once that party agrees to trade. The trade request times
+out after a certain time - this is handled by a Script. Once trade
+starts, the CmdsetTrade cmdset is initiated on both parties along with
+the commands relevant for the trading.
+
+- Ideas for NPC bartering:
+
+This module is primarily intended for trade between two players. But
+it can also in principle be used for a player negotiating with an
+AI-controlled NPC. If the NPC uses normal commands they can use it
+directly -- but more efficient is to have the NPC object send its
+replies directly through the tradehandler to the player. One may want
+to add some functionality to the decline command, so players can
+decline specific objects in the NPC offer (decline <object>) and allow
+the AI to maybe offer something else and make it into a proper
+barter. Along with an AI that "needs" things or has some sort of
+personality in the trading, this can make bartering with NPCs at least
+moderately more interesting than just plain 'buy'.
+
+- Installation:
+
+Just import the CmdTrade command into (for example) the default
+cmdset. This will make the trade (or barter) command available
+in-game.
+
+"""
+
+fromevenniaimportCommand,DefaultScript,CmdSet
+
+TRADE_TIMEOUT=60# timeout for B to accept trade
+
+
+
[docs]classTradeTimeout(DefaultScript):
+ """
+ This times out the trade request, in case player B did not reply in time.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Called when script is first created
+ """
+ self.key="trade_request_timeout"
+ self.desc="times out trade requests"
+ self.interval=TRADE_TIMEOUT
+ self.start_delay=True
+ self.repeats=1
+ self.persistent=False
+
+
[docs]defat_repeat(self):
+ """
+ called once
+ """
+ ifself.ndb.tradeevent:
+ self.obj.ndb.tradeevent.finish(force=True)
+ self.obj.msg("Trade request timed out.")
+
+
[docs]defis_valid(self):
+ """
+ Only valid if the trade has not yet started
+ """
+ returnself.obj.ndb.tradeeventandnotself.obj.ndb.tradeevent.trade_started
+
+
+
[docs]classTradeHandler(object):
+ """
+ Objects of this class handles the ongoing trade, notably storing the current
+ offers from each side and wether both have accepted or not.
+ """
+
+
[docs]def__init__(self,part_a,part_b):
+ """
+ Initializes the trade. This is called when part A tries to
+ initiate a trade with part B. The trade will not start until
+ part B repeats this command (B will then call the self.join()
+ command)
+
+ Args:
+ part_a (object): The party trying to start barter.
+ part_b (object): The party asked to barter.
+
+ Notes:
+ We also store the back-reference from the respective party
+ to this object.
+
+ """
+ # parties
+ self.part_a=part_a
+ self.part_b=part_b
+
+ self.part_a.cmdset.add(CmdsetTrade())
+ self.trade_started=False
+ self.part_a.ndb.tradehandler=self
+ # trade variables
+ self.part_a_offers=[]
+ self.part_b_offers=[]
+ self.part_a_accepted=False
+ self.part_b_accepted=False
+
+
[docs]defmsg_other(self,sender,string):
+ """
+ Relay a message to the *other* party without needing to know
+ which party that is. This allows the calling command to not
+ have to worry about which party they are in the handler.
+
+ Args:
+ sender (object): One of A or B. The method will figure
+ out the *other* party to send to.
+ string (str): Text to send.
+ """
+ ifself.part_a==sender:
+ self.part_b.msg(string)
+ elifself.part_b==sender:
+ self.part_a.msg(string)
+ else:
+ # no match, relay to oneself
+ sender.msg(string)ifsenderelseself.part_a.msg(string)
+
+
[docs]defget_other(self,party):
+ """
+ Returns the other party of the trade
+
+ Args:
+ party (object): One of the parties of the negotiation
+
+ Returns:
+ party_other (object): The other party, not the first party.
+
+ """
+ ifself.part_a==party:
+ returnself.part_b
+ ifself.part_b==party:
+ returnself.part_a
+ returnNone
+
+
[docs]defjoin(self,part_b):
+ """
+ This is used once B decides to join the trade
+
+ Args:
+ part_b (object): The party accepting the barter.
+
+ """
+ ifself.part_b==part_b:
+ self.part_b.ndb.tradehandler=self
+ self.part_b.cmdset.add(CmdsetTrade())
+ self.trade_started=True
+ returnTrue
+ returnFalse
+
+
[docs]defunjoin(self,part_b):
+ """
+ This is used if B decides not to join the trade.
+
+ Args:
+ part_b (object): The party leaving the barter.
+
+ """
+ ifself.part_b==part_b:
+ self.finish(force=True)
+ returnTrue
+ returnFalse
+
+
[docs]defoffer(self,party,*args):
+ """
+ Change the current standing offer. We leave it up to the
+ command to do the actual checks that the offer consists
+ of real, valid, objects.
+
+ Args:
+ party (object): Who is making the offer
+ args (objects or str): Offerings.
+
+ """
+ ifself.trade_started:
+ # reset accept statements whenever an offer changes
+ self.part_a_accepted=False
+ self.part_b_accepted=False
+ ifparty==self.part_a:
+ self.part_a_offers=list(args)
+ elifparty==self.part_b:
+ self.part_b_offers=list(args)
+ else:
+ raiseValueError
+
+
[docs]deflist(self):
+ """
+ List current offers.
+
+ Returns:
+ offers (tuple): A tuple with two lists, (A_offers, B_offers).
+
+ """
+ returnself.part_a_offers,self.part_b_offers
+
+
[docs]defsearch(self,offername):
+ """
+ Search current offers.
+
+ Args:
+ offername (str or int): Object to search for, or its index in
+ the list of offered items.
+
+ Returns:
+ offer (object): An object on offer, based on the search criterion.
+
+ """
+ all_offers=self.part_a_offers+self.part_b_offers
+ ifisinstance(offername,int):
+ # an index to return
+ if0<=offername<len(all_offers):
+ returnall_offers[offername]
+
+ all_keys=[offer.keyforofferinall_offers]
+ try:
+ imatch=all_keys.index(offername)
+ returnall_offers[imatch]
+ exceptValueError:
+ forofferinall_offers:
+ ifoffer.aliases.get(offername):
+ returnoffer
+ returnNone
+
+
[docs]defaccept(self,party):
+ """
+ Accept the current offer.
+
+ Args:
+ party (object): The party accepting the deal.
+
+ Returns:
+ result (object): `True` if this closes the deal, `False`
+ otherwise
+
+ Notes:
+ This will only close the deal if both parties have
+ accepted independently. This is done by calling the
+ `finish()` method.
+
+ """
+ ifself.trade_started:
+ ifparty==self.part_a:
+ self.part_a_accepted=True
+ elifparty==self.part_b:
+ self.part_b_accepted=True
+ else:
+ raiseValueError
+ returnself.finish()# try to close the deal
+ returnFalse
+
+
[docs]defdecline(self,party):
+ """
+ Decline the offer (or change one's mind).
+
+ Args:
+ party (object): Party declining the deal.
+
+ Returns:
+ did_decline (bool): `True` if there was really an
+ `accepted` status to change, `False` otherwise.
+
+ Notes:
+ If previously having used the `accept` command, this
+ function will only work as long as the other party has not
+ yet accepted.
+
+ """
+ ifself.trade_started:
+ ifparty==self.part_a:
+ ifself.part_a_accepted:
+ self.part_a_accepted=False
+ returnTrue
+ returnFalse
+ elifparty==self.part_b:
+ ifself.part_b_accepted:
+ self.part_b_accepted=False
+ returnTrue
+ returnFalse
+ else:
+ raiseValueError
+ returnFalse
+
+
[docs]deffinish(self,force=False):
+ """
+ Conclude trade - move all offers and clean up
+
+ Args:
+ force (bool, optional): Force cleanup regardless of if the
+ trade was accepted or not (if not, no goods will change
+ hands but trading will stop anyway)
+ Returns:
+ result (bool): If the finish was successful.
+
+ """
+ fin=False
+ ifself.trade_startedandself.part_a_acceptedandself.part_b_accepted:
+ # both accepted - move objects before cleanup
+ forobjinself.part_a_offers:
+ obj.location=self.part_b
+ forobjinself.part_b_offers:
+ obj.location=self.part_a
+ fin=True
+ iffinorforce:
+ # cleanup
+ self.part_a.cmdset.delete("cmdset_trade")
+ self.part_b.cmdset.delete("cmdset_trade")
+ self.part_a_offers=None
+ self.part_b_offers=None
+ self.part_a.scripts.stop("trade_request_timeout")
+ # this will kill it also from B
+ delself.part_a.ndb.tradehandler
+ ifself.part_b.ndb.tradehandler:
+ delself.part_b.ndb.tradehandler
+ returnTrue
+ returnFalse
+
+
+# trading commands (will go into CmdsetTrade, initialized by the
+# CmdTrade command further down).
+
+
+
[docs]classCmdTradeBase(Command):
+ """
+ Base command for Trade commands to inherit from. Implements the
+ custom parsing.
+ """
+
+
[docs]defparse(self):
+ """
+ Parse the relevant parts and make it easily
+ available to the command
+ """
+ self.args=self.args.strip()
+ self.tradehandler=self.caller.ndb.tradehandler
+ self.part_a=self.tradehandler.part_a
+ self.part_b=self.tradehandler.part_b
+
+ self.other=self.tradehandler.get_other(self.caller)
+ self.msg_other=self.tradehandler.msg_other
+
+ self.trade_started=self.tradehandler.trade_started
+ self.emote=""
+ self.str_caller="Your trade action: %s"
+ self.str_other="%s:s trade action: "%self.caller.key+"%s"
+ if":"inself.args:
+ self.args,self.emote=[part.strip()forpartinself.args.rsplit(":",1)]
+ self.str_caller='You say, "'+self.emote+'"\n [%s]'
+ ifself.caller.has_account:
+ self.str_other='|c%s|n says, "'%self.caller.key+self.emote+'"\n [%s]'
+ else:
+ self.str_other='%s says, "'%self.caller.key+self.emote+'"\n [%s]'
+
+
+# trade help
+
+
+
[docs]classCmdTradeHelp(CmdTradeBase):
+ """
+ help command for the trade system.
+
+ Usage:
+ trade help
+
+ Displays help for the trade commands.
+ """
+
+ key="trade help"
+ locks="cmd:all()"
+ help_category="Trade"
+
+
[docs]deffunc(self):
+ """Show the help"""
+ string="""
+ Trading commands
+
+ |woffer <objects> [:emote]|n
+ offer one or more objects for trade. The emote can be used for
+ RP/arguments. A new offer will require both parties to re-accept
+ it again.
+ |waccept [:emote]|n
+ accept the currently standing offer from both sides. Also 'agree'
+ works. Once both have accepted, the deal is finished and goods
+ will change hands.
+ |wdecline [:emote]|n
+ change your mind and remove a previous accept (until other
+ has also accepted)
+ |wstatus|n
+ show the current offers on each side of the deal. Also 'offers'
+ and 'deal' works.
+ |wevaluate <nr> or <offer>|n
+ examine any offer in the deal. List them with the 'status' command.
+ |wend trade|n
+ end the negotiations prematurely. No trade will take place.
+
+ You can also use |wemote|n, |wsay|n etc to discuss
+ without making a decision or offer.
+ """
+ self.caller.msg(string)
+
+
+# offer
+
+
+
[docs]classCmdOffer(CmdTradeBase):
+ """
+ offer one or more items in trade.
+
+ Usage:
+ offer <object> [, object2, ...][:emote]
+
+ Offer objects in trade. This will replace the currently
+ standing offer.
+ """
+
+ key="offer"
+ locks="cmd:all()"
+ help_category="Trading"
+
+
[docs]deffunc(self):
+ """implement the offer"""
+
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("Usage: offer <object> [, object2, ...] [:emote]")
+ return
+ ifnotself.trade_started:
+ caller.msg("Wait until the other party has accepted to trade with you.")
+ return
+
+ # gather all offers
+ offers=[part.strip()forpartinself.args.split(",")]
+ offerobjs=[]
+ foroffernameinoffers:
+ obj=caller.search(offername)
+ ifnotobj:
+ return
+ offerobjs.append(obj)
+ self.tradehandler.offer(self.caller,*offerobjs)
+
+ # output
+ iflen(offerobjs)>1:
+ objnames=(
+ ", ".join("|w%s|n"%obj.keyforobjinofferobjs[:-1])
+ +" and |w%s|n"%offerobjs[-1].key
+ )
+ else:
+ objnames="|w%s|n"%offerobjs[0].key
+
+ caller.msg(self.str_caller%("You offer %s"%objnames))
+ self.msg_other(caller,self.str_other%("They offer %s"%objnames))
+
+
+# accept
+
+
+
[docs]classCmdAccept(CmdTradeBase):
+ """
+ accept the standing offer
+
+ Usage:
+ accept [:emote]
+ agreee [:emote]
+
+ This will accept the current offer. The other party must also accept
+ for the deal to go through. You can use the 'decline' command to change
+ your mind as long as the other party has not yet accepted. You can inspect
+ the current offer using the 'offers' command.
+ """
+
+ key="accept"
+ aliases=["agree"]
+ locks="cmd:all()"
+ help_category="Trading"
+
+
[docs]deffunc(self):
+ """accept the offer"""
+ caller=self.caller
+ ifnotself.trade_started:
+ caller.msg("Wait until the other party has accepted to trade with you.")
+ return
+ ifself.tradehandler.accept(self.caller):
+ # deal finished. Trade ended and cleaned.
+ caller.msg(
+ self.str_caller
+ %"You |gaccept|n the deal. |gDeal is made and goods changed hands.|n"
+ )
+ self.msg_other(
+ caller,
+ self.str_other%"%s |gaccepts|n the deal."
+ " |gDeal is made and goods changed hands.|n"%caller.key,
+ )
+ else:
+ # a one-sided accept.
+ caller.msg(
+ self.str_caller
+ %"You |Gaccept|n the offer. %s must now also accept."
+ %self.other.key
+ )
+ self.msg_other(
+ caller,
+ self.str_other%"%s |Gaccepts|n the offer. You must now also accept."%caller.key,
+ )
+
+
+# decline
+
+
+
[docs]classCmdDecline(CmdTradeBase):
+ """
+ decline the standing offer
+
+ Usage:
+ decline [:emote]
+
+ This will decline a previously 'accept'ed offer (so this allows you to
+ change your mind). You can only use this as long as the other party
+ has not yet accepted the deal. Also, changing the offer will automatically
+ decline the old offer.
+ """
+
+ key="decline"
+ locks="cmd:all()"
+ help_category="Trading"
+
+
[docs]deffunc(self):
+ """decline the offer"""
+ caller=self.caller
+ ifnotself.trade_started:
+ caller.msg("Wait until the other party has accepted to trade with you.")
+ return
+ offer_a,offer_b=self.tradehandler.list()
+ ifnotoffer_aornotoffer_b:
+ caller.msg("No offers have been made yet, so there is nothing to decline.")
+ return
+ ifself.tradehandler.decline(self.caller):
+ # changed a previous accept
+ caller.msg(self.str_caller%"You change your mind, |Rdeclining|n the current offer.")
+ self.msg_other(
+ caller,
+ self.str_other
+ %"%s changes their mind, |Rdeclining|n the current offer."
+ %caller.key,
+ )
+ else:
+ # no acceptance to change
+ caller.msg(self.str_caller%"You |Rdecline|n the current offer.")
+ self.msg_other(caller,self.str_other%"%s declines the current offer."%caller.key)
+
+
+# evaluate
+
+# Note: This version only shows the description. If your particular game
+# lists other important properties of objects (such as weapon damage, weight,
+# magical properties, ammo requirements or whatnot), then you need to add this
+# here.
+
+
+
[docs]classCmdEvaluate(CmdTradeBase):
+ """
+ evaluate objects on offer
+
+ Usage:
+ evaluate <offered object>
+
+ This allows you to examine any object currently on offer, to
+ determine if it's worth your while.
+ """
+
+ key="evaluate"
+ aliases=["eval"]
+ locks="cmd:all()"
+ help_category="Trading"
+
+
[docs]deffunc(self):
+ """evaluate an object"""
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("Usage: evaluate <offered object>")
+ return
+ # we also accept indices
+ try:
+ ind=int(self.args)
+ self.args=ind-1
+ exceptException:
+ # not a valid index - ignore
+ pass
+
+ offer=self.tradehandler.search(self.args)
+ ifnotoffer:
+ caller.msg("No offer matching '%s' was found."%self.args)
+ return
+ # show the description
+ caller.msg(offer.db.desc)
+
+
+# status
+
+
+
[docs]classCmdStatus(CmdTradeBase):
+ """
+ show a list of the current deal
+
+ Usage:
+ status
+ deal
+ offers
+
+ Shows the currently suggested offers on each sides of the deal. To
+ accept the current deal, use the 'accept' command. Use 'offer' to
+ change your deal. You might also want to use 'say', 'emote' etc to
+ try to influence the other part in the deal.
+ """
+
+ key="status"
+ aliases=["offers","deal"]
+ locks="cmd:all()"
+ help_category="Trading"
+
+
[docs]classCmdFinish(CmdTradeBase):
+ """
+ end the trade prematurely
+
+ Usage:
+ end trade [:say]
+ finish trade [:say]
+
+ This ends the trade prematurely. No trade will take place.
+
+ """
+
+ key="end trade"
+ aliases="finish trade"
+ locks="cmd:all()"
+ help_category="Trading"
+
+
[docs]deffunc(self):
+ """end trade"""
+ caller=self.caller
+ self.tradehandler.finish(force=True)
+ caller.msg(self.str_caller%"You |raborted|n trade. No deal was made.")
+ self.msg_other(
+ caller,self.str_other%"%s |raborted|n trade. No deal was made."%caller.key
+ )
+
+
+# custom Trading cmdset
+
+
+
[docs]classCmdsetTrade(CmdSet):
+ """
+ This cmdset is added when trade is initated. It is handled by the
+ trade event handler.
+ """
+
+ key="cmdset_trade"
+
+
+
+
+# access command - once both have given this, this will create the
+# trading cmdset to start trade.
+
+
+
[docs]classCmdTrade(Command):
+ """
+ Initiate trade with another party
+
+ Usage:
+ trade <other party> [:say]
+ trade <other party> accept [:say]
+ trade <other party> decline [:say]
+
+ Initiate trade with another party. The other party needs to repeat
+ this command with trade accept/decline within a minute in order to
+ properly initiate the trade action. You can use the decline option
+ yourself if you want to retract an already suggested trade. The
+ optional say part works like the say command and allows you to add
+ info to your choice.
+ """
+
+ key="trade"
+ aliases=["barter"]
+ locks="cmd:all()"
+ help_category="General"
+
+
[docs]deffunc(self):
+ """Initiate trade"""
+
+ ifnotself.args:
+ ifself.caller.ndb.tradehandlerandself.caller.ndb.tradeevent.trade_started:
+ self.caller.msg("You are already in a trade. Use 'end trade' to abort it.")
+ else:
+ self.caller.msg("Usage: trade <other party> [accept|decline] [:emote]")
+ return
+ self.args=self.args.strip()
+
+ # handle the emote manually here
+ selfemote=""
+ theiremote=""
+ if":"inself.args:
+ self.args,emote=[part.strip()forpartinself.args.rsplit(":",1)]
+ selfemote='You say, "%s"\n '%emote
+ ifself.caller.has_account:
+ theiremote='|c%s|n says, "%s"\n '%(self.caller.key,emote)
+ else:
+ theiremote='%s says, "%s"\n '%(self.caller.key,emote)
+
+ # for the sake of this command, the caller is always part_a; this
+ # might not match the actual name in tradehandler (in the case of
+ # using this command to accept/decline a trade invitation).
+ part_a=self.caller
+ accept="accept"inself.args
+ decline="decline"inself.args
+ ifaccept:
+ part_b=self.args.rstrip("accept").strip()
+ elifdecline:
+ part_b=self.args.rstrip("decline").strip()
+ else:
+ part_b=self.args
+ part_b=self.caller.search(part_b)
+ ifnotpart_b:
+ return
+ ifpart_a==part_b:
+ part_a.msg("You play trader with yourself.")
+ return
+
+ # messages
+ str_init_a="You ask to trade with %s. They need to accept within %s secs."
+ str_init_b="%s wants to trade with you. Use |wtrade %s accept/decline [:emote]|n to answer (within %s secs)."
+ str_noinit_a="%s declines the trade"
+ str_noinit_b="You decline trade with %s."
+ str_start_a="%s starts to trade with you. See |wtrade help|n for aid."
+ str_start_b="You start to trade with %s. See |wtrade help|n for aid."
+
+ ifnot(acceptordecline):
+ # initialization of trade
+ ifself.caller.ndb.tradehandler:
+ # trying to start trade without stopping a previous one
+ ifself.caller.ndb.tradehandler.trade_started:
+ string="You are already in trade with %s. You need to end trade first."
+ else:
+ string="You are already trying to initiate trade with %s. You need to decline that trade first."
+ self.caller.msg(string%part_b.key)
+ elifpart_b.ndb.tradehandlerandpart_b.ndb.tradehandler.part_b==part_a:
+ # this is equivalent to part_a accepting a trade from part_b (so roles are reversed)
+ part_b.ndb.tradehandler.join(part_a)
+ part_b.msg(theiremote+str_start_a%part_a.key)
+ part_a.msg(selfemote+str_start_b%part_b.key)
+ else:
+ # initiate a new trade
+ TradeHandler(part_a,part_b)
+ part_a.msg(selfemote+str_init_a%(part_b.key,TRADE_TIMEOUT))
+ part_b.msg(theiremote+str_init_b%(part_a.key,part_a.key,TRADE_TIMEOUT))
+ part_a.scripts.add(TradeTimeout)
+ return
+ elifaccept:
+ # accept a trade proposal from part_b (so roles are reversed)
+ ifpart_a.ndb.tradehandler:
+ # already in a trade
+ part_a.msg(
+ "You are already in trade with %s. You need to end that first."%part_b.key
+ )
+ return
+ ifpart_b.ndb.tradehandler.join(part_a):
+ part_b.msg(theiremote+str_start_a%part_a.key)
+ part_a.msg(selfemote+str_start_b%part_b.key)
+ else:
+ part_a.msg("No trade proposal to accept.")
+ return
+ else:
+ # decline trade proposal from part_b (so roles are reversed)
+ ifpart_a.ndb.tradehandlerandpart_a.ndb.tradehandler.part_b==part_a:
+ # stopping an invite
+ part_a.ndb.tradehandler.finish(force=True)
+ part_b.msg(theiremote+"%s aborted trade attempt with you."%part_a)
+ part_a.msg(selfemote+"You aborted the trade attempt with %s."%part_b)
+ elifpart_b.ndb.tradehandlerandpart_b.ndb.tradehandler.unjoin(part_a):
+ part_b.msg(theiremote+str_noinit_a%part_a.key)
+ part_a.msg(selfemote+str_noinit_b%part_b.key)
+ else:
+ part_a.msg("No trade proposal to decline.")
+ return
+"""
+Module containing the building menu system.
+
+Evennia contributor: vincent-lg 2018
+
+Building menus are in-game menus, not unlike `EvMenu` though using a
+different approach. Building menus have been specifically designed to edit
+information as a builder. Creating a building menu in a command allows
+builders quick-editing of a given object, like a room. If you follow the
+steps below to add the contrib, you will have access to an `@edit` command
+that will edit any default object offering to change its key and description.
+
+1. Import the `GenericBuildingCmd` class from this contrib in your `mygame/commands/default_cmdset.py` file:
+
+ ```python
+ from evennia.contrib.building_menu import GenericBuildingCmd
+ ```
+
+2. Below, add the command in the `CharacterCmdSet`:
+
+ ```python
+ # ... These lines should exist in the file
+ class CharacterCmdSet(default_cmds.CharacterCmdSet):
+ key = "DefaultCharacter"
+
+ def at_cmdset_creation(self):
+ super(CharacterCmdSet, self).at_cmdset_creation()
+ # ... add the line below
+ self.add(GenericBuildingCmd())
+ ```
+
+The `@edit` command will allow you to edit any object. You will need to
+specify the object name or ID as an argument. For instance: `@edit here`
+will edit the current room. However, building menus can perform much more
+than this very simple example, read on for more details.
+
+Building menus can be set to edit about anything. Here is an example of
+output you could obtain when editing the room:
+
+```
+ Editing the room: Limbo(#2)
+
+ [T]itle: the limbo room
+ [D]escription
+ This is the limbo room. You can easily change this default description,
+ either by using the |y@desc/edit|n command, or simply by entering this
+ menu (enter |yd|n).
+ [E]xits:
+ north to A parking(#4)
+ [Q]uit this menu
+```
+
+From there, you can open the title choice by pressing t. You can then
+change the room title by simply entering text, and go back to the
+main menu entering @ (all this is customizable). Press q to quit this menu.
+
+The first thing to do is to create a new module and place a class
+inheriting from `BuildingMenu` in it.
+
+```python
+from evennia.contrib.building_menu import BuildingMenu
+
+class RoomBuildingMenu(BuildingMenu):
+ # ...
+```
+
+Next, override the `init` method. You can add choices (like the title,
+description, and exits choices as seen above) by using the `add_choice`
+method.
+
+```
+class RoomBuildingMenu(BuildingMenu):
+ def init(self, room):
+ self.add_choice("title", "t", attr="key")
+```
+
+That will create the first choice, the title choice. If one opens your menu
+and enter t, she will be in the title choice. She can change the title
+(it will write in the room's `key` attribute) and then go back to the
+main menu using `@`.
+
+`add_choice` has a lot of arguments and offers a great deal of
+flexibility. The most useful ones is probably the usage of callbacks,
+as you can set almost any argument in `add_choice` to be a callback, a
+function that you have defined above in your module. This function will be
+called when the menu element is triggered.
+
+Notice that in order to edit a description, the best method to call isn't
+`add_choice`, but `add_choice_edit`. This is a convenient shortcut
+which is available to quickly open an `EvEditor` when entering this choice
+and going back to the menu when the editor closes.
+
+```
+class RoomBuildingMenu(BuildingMenu):
+ def init(self, room):
+ self.add_choice("title", "t", attr="key")
+ self.add_choice_edit("description", key="d", attr="db.desc")
+```
+
+When you wish to create a building menu, you just need to import your
+class, create it specifying your intended caller and object to edit,
+then call `open`:
+
+```python
+from <wherever> import RoomBuildingMenu
+
+class CmdEdit(Command):
+
+ key = "redit"
+
+ def func(self):
+ menu = RoomBuildingMenu(self.caller, self.caller.location)
+ menu.open()
+```
+
+This is a very short introduction. For more details, see the online tutorial
+(https://github.com/evennia/evennia/wiki/Building-menus) or read the
+heavily-documented code below.
+
+"""
+
+frominspectimportgetargspec
+fromtextwrapimportdedent
+
+fromdjango.confimportsettings
+fromevenniaimportCommand,CmdSet
+fromevennia.commandsimportcmdhandler
+fromevennia.utils.ansiimportstrip_ansi
+fromevennia.utils.eveditorimportEvEditor
+fromevennia.utils.loggerimportlog_err,log_trace
+fromevennia.utils.utilsimportclass_from_module
+
+
+# Constants
+_MAX_TEXT_WIDTH=settings.CLIENT_DEFAULT_WIDTH
+_CMD_NOMATCH=cmdhandler.CMD_NOMATCH
+_CMD_NOINPUT=cmdhandler.CMD_NOINPUT
+
+
+# Private functions
+def_menu_loadfunc(caller):
+ obj,attr=caller.attributes.get("_building_menu_to_edit",[None,None])
+ ifobjandattr:
+ forpartinattr.split(".")[:-1]:
+ obj=getattr(obj,part)
+
+ returngetattr(obj,attr.split(".")[-1])ifobjisnotNoneelse""
+
+
+def_menu_savefunc(caller,buf):
+ obj,attr=caller.attributes.get("_building_menu_to_edit",[None,None])
+ ifobjandattr:
+ forpartinattr.split(".")[:-1]:
+ obj=getattr(obj,part)
+
+ setattr(obj,attr.split(".")[-1],buf)
+
+ caller.attributes.remove("_building_menu_to_edit")
+ returnTrue
+
+
+def_menu_quitfunc(caller):
+ caller.cmdset.add(
+ BuildingMenuCmdSet,
+ permanent=caller.ndb._building_menuandcaller.ndb._building_menu.persistentorFalse,
+ )
+ ifcaller.ndb._building_menu:
+ caller.ndb._building_menu.move(back=True)
+
+
+def_call_or_get(value,menu=None,choice=None,string=None,obj=None,caller=None):
+ """
+ Call the value, if appropriate, or just return it.
+
+ Args:
+ value (any): the value to obtain. It might be a callable (see note).
+
+ Keyword Args:
+ menu (BuildingMenu, optional): the building menu to pass to value
+ if it is a callable.
+ choice (Choice, optional): the choice to pass to value if a callable.
+ string (str, optional): the raw string to pass to value if a callback.
+ obj (Object): the object to pass to value if a callable.
+ caller (Account or Object, optional): the caller to pass to value
+ if a callable.
+
+ Returns:
+ The value itself. If the argument is a function, call it with
+ specific arguments (see note).
+
+ Note:
+ If `value` is a function, call it with varying arguments. The
+ list of arguments will depend on the argument names in your callable.
+ - An argument named `menu` will contain the building menu or None.
+ - The `choice` argument will contain the choice or None.
+ - The `string` argument will contain the raw string or None.
+ - The `obj` argument will contain the object or None.
+ - The `caller` argument will contain the caller or None.
+ - Any other argument will contain the object (`obj`).
+ Thus, you could define callbacks like this:
+ def on_enter(menu, caller, obj):
+ def on_nomatch(string, choice, menu):
+ def on_leave(caller, room): # note that room will contain `obj`
+
+ """
+ ifcallable(value):
+ # Check the function arguments
+ kwargs={}
+ spec=getargspec(value)
+ args=spec.args
+ ifspec.keywords:
+ kwargs.update(dict(menu=menu,choice=choice,string=string,obj=obj,caller=caller))
+ else:
+ if"menu"inargs:
+ kwargs["menu"]=menu
+ if"choice"inargs:
+ kwargs["choice"]=choice
+ if"string"inargs:
+ kwargs["string"]=string
+ if"obj"inargs:
+ kwargs["obj"]=obj
+ if"caller"inargs:
+ kwargs["caller"]=caller
+
+ # Fill missing arguments
+ forarginargs:
+ ifargnotinkwargs:
+ kwargs[arg]=obj
+
+ # Call the function and return its return value
+ returnvalue(**kwargs)
+
+ returnvalue
+
+
+# Helper functions, to be used in menu choices
+
+
+
[docs]defmenu_setattr(menu,choice,obj,string):
+ """
+ Set the value at the specified attribute.
+
+ Args:
+ menu (BuildingMenu): the menu object.
+ choice (Chocie): the specific choice.
+ obj (Object): the object to modify.
+ string (str): the string with the new value.
+
+ Note:
+ This function is supposed to be used as a default to
+ `BuildingMenu.add_choice`, when an attribute name is specified
+ (in the `attr` argument) but no function `on_nomatch` is defined.
+
+ """
+ attr=getattr(choice,"attr",None)ifchoiceelseNone
+ ifchoiceisNoneorstringisNoneorattrisNoneormenuisNone:
+ log_err(
+ dedent(
+ """
+ The `menu_setattr` function was called to set the attribute {} of object {} to {},
+ but the choice {} of menu {} or another information is missing.
+ """.format(
+ attr,obj,repr(string),choice,menu
+ )
+ ).strip("\n")
+ ).strip()
+ return
+
+ forpartinattr.split(".")[:-1]:
+ obj=getattr(obj,part)
+
+ setattr(obj,attr.split(".")[-1],string)
+ returnTrue
+
+
+
[docs]defmenu_quit(caller,menu):
+ """
+ Quit the menu, closing the CmdSet.
+
+ Args:
+ caller (Account or Object): the caller.
+ menu (BuildingMenu): the building menu to close.
+
+ Note:
+ This callback is used by default when using the
+ `BuildingMenu.add_choice_quit` method. This method is called
+ automatically if the menu has no parent.
+
+ """
+ ifcallerisNoneormenuisNone:
+ log_err(
+ "The function `menu_quit` was called with missing "
+ "arguments: caller={}, menu={}".format(caller,menu)
+ )
+
+ ifcaller.cmdset.has(BuildingMenuCmdSet):
+ menu.close()
+ caller.msg("Closing the building menu.")
+ else:
+ caller.msg("It looks like the building menu has already been closed.")
+
+
+
[docs]defmenu_edit(caller,choice,obj):
+ """
+ Open the EvEditor to edit a specified attribute.
+
+ Args:
+ caller (Account or Object): the caller.
+ choice (Choice): the choice object.
+ obj (Object): the object to edit.
+
+ """
+ attr=choice.attr
+ caller.db._building_menu_to_edit=(obj,attr)
+ caller.cmdset.remove(BuildingMenuCmdSet)
+ EvEditor(
+ caller,
+ loadfunc=_menu_loadfunc,
+ savefunc=_menu_savefunc,
+ quitfunc=_menu_quitfunc,
+ key="editor",
+ persistent=True,
+ )
+
+
+# Building menu commands and CmdSet
+
+
+
[docs]classCmdNoInput(Command):
+
+ """No input has been found."""
+
+ key=_CMD_NOINPUT
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Display the menu or choice text."""
+ ifself.menu:
+ self.menu.display()
+ else:
+ log_err("When CMDNOINPUT was called, the building menu couldn't be found")
+ self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n")
+ self.caller.cmdset.delete(BuildingMenuCmdSet)
+
+
+
[docs]classCmdNoMatch(Command):
+
+ """No input has been found."""
+
+ key=_CMD_NOMATCH
+ locks="cmd:all()"
+
+
[docs]defat_cmdset_creation(self):
+ """Populates the cmdset with commands."""
+ caller=self.cmdsetobj
+
+ # The caller could recall the menu
+ menu=caller.ndb._building_menu
+ ifmenuisNone:
+ menu=caller.db._building_menu
+ ifmenu:
+ menu=BuildingMenu.restore(caller)
+
+ cmds=[CmdNoInput,CmdNoMatch]
+ forcmdincmds:
+ self.add(cmd(building_menu=menu))
+
+
+# Menu classes
+
+
+
[docs]classChoice(object):
+
+ """A choice object, created by `add_choice`."""
+
+
[docs]def__init__(
+ self,
+ title,
+ key=None,
+ aliases=None,
+ attr=None,
+ text=None,
+ glance=None,
+ on_enter=None,
+ on_nomatch=None,
+ on_leave=None,
+ menu=None,
+ caller=None,
+ obj=None,
+ ):
+ """Constructor.
+
+ Args:
+ title (str): the choice's title.
+ key (str, optional): the key of the letters to type to access
+ the choice. If not set, try to guess it based on the title.
+ aliases (list of str, optional): the allowed aliases for this choice.
+ attr (str, optional): the name of the attribute of 'obj' to set.
+ text (str or callable, optional): a text to be displayed for this
+ choice. It can be a callable.
+ glance (str or callable, optional): an at-a-glance summary of the
+ sub-menu shown in the main menu. It can be set to
+ display the current value of the attribute in the
+ main menu itself.
+ menu (BuildingMenu, optional): the parent building menu.
+ on_enter (callable, optional): a callable to call when the
+ caller enters into the choice.
+ on_nomatch (callable, optional): a callable to call when no
+ match is entered in the choice.
+ on_leave (callable, optional): a callable to call when the caller
+ leaves the choice.
+ caller (Account or Object, optional): the caller.
+ obj (Object, optional): the object to edit.
+
+ """
+ self.title=title
+ self.key=key
+ self.aliases=aliases
+ self.attr=attr
+ self.text=text
+ self.glance=glance
+ self.on_enter=on_enter
+ self.on_nomatch=on_nomatch
+ self.on_leave=on_leave
+ self.menu=menu
+ self.caller=caller
+ self.obj=obj
+
+ def__repr__(self):
+ return"<Choice (title={}, key={})>".format(self.title,self.key)
+
+ @property
+ defkeys(self):
+ """Return a tuple of keys separated by `sep_keys`."""
+ returntuple(self.key.split(self.menu.sep_keys))
+
+
[docs]defformat_text(self):
+ """Format the choice text and return it, or an empty string."""
+ text=""
+ ifself.text:
+ text=_call_or_get(
+ self.text,menu=self.menu,choice=self,string="",caller=self.caller,obj=self.obj
+ )
+ text=dedent(text.strip("\n"))
+ text=text.format(obj=self.obj,caller=self.caller)
+
+ returntext
+
+
[docs]defenter(self,string):
+ """Called when the user opens the choice.
+
+ Args:
+ string (str): the entered string.
+
+ """
+ ifself.on_enter:
+ _call_or_get(
+ self.on_enter,
+ menu=self.menu,
+ choice=self,
+ string=string,
+ caller=self.caller,
+ obj=self.obj,
+ )
+
+
[docs]defnomatch(self,string):
+ """Called when the user entered something in the choice.
+
+ Args:
+ string (str): the entered string.
+
+ Returns:
+ to_display (bool): The return value of `nomatch` if set or
+ `True`. The rule is that if `no_match` returns `True`,
+ then the choice or menu is displayed.
+
+ """
+ ifself.on_nomatch:
+ return_call_or_get(
+ self.on_nomatch,
+ menu=self.menu,
+ choice=self,
+ string=string,
+ caller=self.caller,
+ obj=self.obj,
+ )
+
+ returnTrue
+
+
[docs]defleave(self,string):
+ """Called when the user closes the choice.
+
+ Args:
+ string (str): the entered string.
+
+ """
+ ifself.on_leave:
+ _call_or_get(
+ self.on_leave,
+ menu=self.menu,
+ choice=self,
+ string=string,
+ caller=self.caller,
+ obj=self.obj,
+ )
+
+
+
[docs]classBuildingMenu(object):
+
+ """
+ Class allowing to create and set building menus to edit specific objects.
+
+ A building menu is somewhat similar to `EvMenu`, but designed to edit
+ objects by builders, although it can be used for players in some contexts.
+ You could, for instance, create a building menu to edit a room with a
+ sub-menu for the room's key, another for the room's description,
+ another for the room's exits, and so on.
+
+ To add choices (simple sub-menus), you should call `add_choice` (see the
+ full documentation of this method). With most arguments, you can
+ specify either a plain string or a callback. This callback will be
+ called when the operation is to be performed.
+
+ Some methods are provided for frequent needs (see the `add_choice_*`
+ methods). Some helper functions are defined at the top of this
+ module in order to be used as arguments to `add_choice`
+ in frequent cases.
+
+ """
+
+ keys_go_back=["@"]# The keys allowing to go back in the menu tree
+ sep_keys="."# The key separator for menus with more than 2 levels
+ joker_key="*"# The special key meaning "anything" in a choice key
+ min_shortcut=1# The minimum length of shortcuts when `key` is not set
+
+
[docs]def__init__(
+ self,
+ caller=None,
+ obj=None,
+ title="Building menu: {obj}",
+ keys=None,
+ parents=None,
+ persistent=False,
+ ):
+ """Constructor, you shouldn't override. See `init` instead.
+
+ Args:
+ caller (Account or Object): the caller.
+ obj (Object): the object to be edited, like a room.
+ title (str, optional): the menu title.
+ keys (list of str, optional): the starting menu keys (None
+ to start from the first level).
+ parents (tuple, optional): information for parent menus,
+ automatically supplied.
+ persistent (bool, optional): should this building menu
+ survive a reload/restart?
+
+ Note:
+ If some of these options have to be changed, it is
+ preferable to do so in the `init` method and not to
+ override `__init__`. For instance:
+ class RoomBuildingMenu(BuildingMenu):
+ def init(self, room):
+ self.title = "Menu for room: {obj.key}(#{obj.id})"
+ # ...
+
+ """
+ self.caller=caller
+ self.obj=obj
+ self.title=title
+ self.keys=keysor[]
+ self.parents=parentsor()
+ self.persistent=persistent
+ self.choices=[]
+ self.cmds={}
+ self.can_quit=False
+
+ ifobj:
+ self.init(obj)
+ ifnotparentsandnotself.can_quit:
+ # Automatically add the menu to quit
+ self.add_choice_quit(key=None)
+ self._add_keys_choice()
+
+ @property
+ defcurrent_choice(self):
+ """Return the current choice or None.
+
+ Returns:
+ choice (Choice): the current choice or None.
+
+ Note:
+ We use the menu keys to identify the current position of
+ the caller in the menu. The menu `keys` hold a list of
+ keys that should match a choice to be usable.
+
+ """
+ menu_keys=self.keys
+ ifnotmenu_keys:
+ returnNone
+
+ forchoiceinself.choices:
+ choice_keys=choice.keys
+ iflen(menu_keys)==len(choice_keys):
+ # Check all the intermediate keys
+ common=True
+ formenu_key,choice_keyinzip(menu_keys,choice_keys):
+ ifchoice_key==self.joker_key:
+ continue
+
+ ifnotisinstance(menu_key,str)ormenu_key!=choice_key:
+ common=False
+ break
+
+ ifcommon:
+ returnchoice
+
+ returnNone
+
+ @property
+ defrelevant_choices(self):
+ """Only return the relevant choices according to the current meny key.
+
+ Returns:
+ relevant (list of Choice object): the relevant choices.
+
+ Note:
+ We use the menu keys to identify the current position of
+ the caller in the menu. The menu `keys` hold a list of
+ keys that should match a choice to be usable.
+
+ """
+ menu_keys=self.keys
+ relevant=[]
+ forchoiceinself.choices:
+ choice_keys=choice.keys
+ ifnotmenu_keysandlen(choice_keys)==1:
+ # First level choice with the menu key empty, that's relevant
+ relevant.append(choice)
+ eliflen(menu_keys)==len(choice_keys)-1:
+ # Check all the intermediate keys
+ common=True
+ formenu_key,choice_keyinzip(menu_keys,choice_keys):
+ ifchoice_key==self.joker_key:
+ continue
+
+ ifnotisinstance(menu_key,str)ormenu_key!=choice_key:
+ common=False
+ break
+
+ ifcommon:
+ relevant.append(choice)
+
+ returnrelevant
+
+ def_save(self):
+ """Save the menu in a attributes on the caller.
+
+ If `persistent` is set to `True`, also save in a persistent attribute.
+
+ """
+ self.caller.ndb._building_menu=self
+
+ ifself.persistent:
+ self.caller.db._building_menu={
+ "class":type(self).__module__+"."+type(self).__name__,
+ "obj":self.obj,
+ "title":self.title,
+ "keys":self.keys,
+ "parents":self.parents,
+ "persistent":self.persistent,
+ }
+
+ def_add_keys_choice(self):
+ """Add the choices' keys if some choices don't have valid keys."""
+ # If choices have been added without keys, try to guess them
+ forchoiceinself.choices:
+ ifnotchoice.key:
+ title=strip_ansi(choice.title.strip()).lower()
+ length=self.min_shortcut
+ whilelength<=len(title):
+ i=0
+ whilei<len(title)-length+1:
+ guess=title[i:i+length]
+ ifguessnotinself.cmds:
+ choice.key=guess
+ break
+
+ i+=1
+
+ ifchoice.key:
+ break
+
+ length+=1
+
+ ifchoice.key:
+ self.cmds[choice.key]=choice
+ else:
+ raiseValueError("Cannot guess the key for {}".format(choice))
+
+
[docs]definit(self,obj):
+ """Create the sub-menu to edit the specified object.
+
+ Args:
+ obj (Object): the object to edit.
+
+ Note:
+ This method is probably to be overridden in your subclasses.
+ Use `add_choice` and its variants to create menu choices.
+
+ """
+ pass
+
+
[docs]defadd_choice(
+ self,
+ title,
+ key=None,
+ aliases=None,
+ attr=None,
+ text=None,
+ glance=None,
+ on_enter=None,
+ on_nomatch=None,
+ on_leave=None,
+ ):
+ """
+ Add a choice, a valid sub-menu, in the current builder menu.
+
+ Args:
+ title (str): the choice's title.
+ key (str, optional): the key of the letters to type to access
+ the sub-neu. If not set, try to guess it based on the
+ choice title.
+ aliases (list of str, optional): the aliases for this choice.
+ attr (str, optional): the name of the attribute of 'obj' to set.
+ This is really useful if you want to edit an
+ attribute of the object (that's a frequent need). If
+ you don't want to do so, just use the `on_*` arguments.
+ text (str or callable, optional): a text to be displayed when
+ the menu is opened It can be a callable.
+ glance (str or callable, optional): an at-a-glance summary of the
+ sub-menu shown in the main menu. It can be set to
+ display the current value of the attribute in the
+ main menu itself.
+ on_enter (callable, optional): a callable to call when the
+ caller enters into this choice.
+ on_nomatch (callable, optional): a callable to call when
+ the caller enters something in this choice. If you
+ don't set this argument but you have specified
+ `attr`, then `obj`.`attr` will be set with the value
+ entered by the user.
+ on_leave (callable, optional): a callable to call when the
+ caller leaves the choice.
+
+ Returns:
+ choice (Choice): the newly-created choice.
+
+ Raises:
+ ValueError if the choice cannot be added.
+
+ Note:
+ Most arguments can be callables, like functions. This has the
+ advantage of allowing great flexibility. If you specify
+ a callable in most of the arguments, the callable should return
+ the value expected by the argument (a str more often than
+ not). For instance, you could set a function to be called
+ to get the menu text, which allows for some filtering:
+ def text_exits(menu):
+ return "Some text to display"
+ class RoomBuildingMenu(BuildingMenu):
+ def init(self):
+ self.add_choice("exits", key="x", text=text_exits)
+
+ The allowed arguments in a callable are specific to the
+ argument names (they are not sensitive to orders, not all
+ arguments have to be present). For more information, see
+ `_call_or_get`.
+
+ """
+ key=keyor""
+ key=key.lower()
+ aliases=aliasesor[]
+ aliases=[a.lower()forainaliases]
+ ifattrandon_nomatchisNone:
+ on_nomatch=menu_setattr
+
+ ifkeyandkeyinself.cmds:
+ raiseValueError(
+ "A conflict exists between {} and {}, both use "
+ "key or alias {}".format(self.cmds[key],title,repr(key))
+ )
+
+ ifattr:
+ ifglanceisNone:
+ glance="{obj."+attr+"}"
+ iftextisNone:
+ text="""
+ -------------------------------------------------------------------------------
+{attr} for {{obj}}(#{{obj.id}})
+
+ You can change this value simply by entering it.
+ Use |y{back}|n to go back to the main menu.
+
+ Current value: |c{{{obj_attr}}}|n
+ """.format(
+ attr=attr,obj_attr="obj."+attr,back="|n or |y".join(self.keys_go_back)
+ )
+
+ choice=Choice(
+ title,
+ key=key,
+ aliases=aliases,
+ attr=attr,
+ text=text,
+ glance=glance,
+ on_enter=on_enter,
+ on_nomatch=on_nomatch,
+ on_leave=on_leave,
+ menu=self,
+ caller=self.caller,
+ obj=self.obj,
+ )
+ self.choices.append(choice)
+ ifkey:
+ self.cmds[key]=choice
+
+ foraliasinaliases:
+ self.cmds[alias]=choice
+
+ returnchoice
+
+
[docs]defadd_choice_edit(
+ self,
+ title="description",
+ key="d",
+ aliases=None,
+ attr="db.desc",
+ glance="\n{obj.db.desc}",
+ on_enter=None,
+ ):
+ """
+ Add a simple choice to edit a given attribute in the EvEditor.
+
+ Args:
+ title (str, optional): the choice's title.
+ key (str, optional): the choice's key.
+ aliases (list of str, optional): the choice's aliases.
+ glance (str or callable, optional): the at-a-glance description.
+ on_enter (callable, optional): a different callable to edit
+ the attribute.
+
+ Returns:
+ choice (Choice): the newly-created choice.
+
+ Note:
+ This is just a shortcut method, calling `add_choice`.
+ If `on_enter` is not set, use `menu_edit` which opens
+ an EvEditor to edit the specified attribute.
+ When the caller closes the editor (with :q), the menu
+ will be re-opened.
+
+ """
+ on_enter=on_enterormenu_edit
+ returnself.add_choice(
+ title,key=key,aliases=aliases,attr=attr,glance=glance,on_enter=on_enter,text=""
+ )
+
+
[docs]defadd_choice_quit(self,title="quit the menu",key="q",aliases=None,on_enter=None):
+ """
+ Add a simple choice just to quit the building menu.
+
+ Args:
+ title (str, optional): the choice's title.
+ key (str, optional): the choice's key.
+ aliases (list of str, optional): the choice's aliases.
+ on_enter (callable, optional): a different callable
+ to quit the building menu.
+
+ Note:
+ This is just a shortcut method, calling `add_choice`.
+ If `on_enter` is not set, use `menu_quit` which simply
+ closes the menu and displays a message. It also
+ removes the CmdSet from the caller. If you supply
+ another callable instead, make sure to do the same.
+
+ """
+ on_enter=on_enterormenu_quit
+ self.can_quit=True
+ returnself.add_choice(title,key=key,aliases=aliases,on_enter=on_enter)
+
+
[docs]defopen(self):
+ """Open the building menu for the caller.
+
+ Note:
+ This method should be called once when the building menu
+ has been instanciated. From there, the building menu will
+ be re-created automatically when the server
+ reloads/restarts, assuming `persistent` is set to `True`.
+
+ """
+ caller=self.caller
+ self._save()
+
+ # Remove the same-key cmdset if exists
+ ifcaller.cmdset.has(BuildingMenuCmdSet):
+ caller.cmdset.remove(BuildingMenuCmdSet)
+
+ self.caller.cmdset.add(BuildingMenuCmdSet,permanent=self.persistent)
+ self.display()
+
+
[docs]defopen_parent_menu(self):
+ """Open the parent menu, using `self.parents`.
+
+ Note:
+ You probably don't need to call this method directly,
+ since the caller can go back to the parent menu using the
+ `keys_go_back` automatically.
+
+ """
+ parents=list(self.parents)
+ ifparents:
+ parent_class,parent_obj,parent_keys=parents[-1]
+ delparents[-1]
+
+ ifself.caller.cmdset.has(BuildingMenuCmdSet):
+ self.caller.cmdset.remove(BuildingMenuCmdSet)
+
+ try:
+ menu_class=class_from_module(parent_class)
+ exceptException:
+ log_trace(
+ "BuildingMenu: attempting to load class {} failed".format(repr(parent_class))
+ )
+ return
+
+ # Create the parent menu
+ try:
+ building_menu=menu_class(
+ self.caller,parent_obj,keys=parent_keys,parents=tuple(parents)
+ )
+ exceptException:
+ log_trace(
+ "An error occurred while creating building menu {}".format(repr(parent_class))
+ )
+ return
+ else:
+ returnbuilding_menu.open()
+
+
[docs]defopen_submenu(self,submenu_class,submenu_obj,parent_keys=None):
+ """
+ Open a sub-menu, closing the current menu and opening the new one.
+
+ Args:
+ submenu_class (str): the submenu class as a Python path.
+ submenu_obj (Object): the object to give to the submenu.
+ parent_keys (list of str, optional): the parent keys when
+ the submenu is closed.
+
+ Note:
+ When the user enters `@` in the submenu, she will go back to
+ the current menu, with the `parent_keys` set as its keys.
+ Therefore, you should set it on the keys of the choice that
+ should be opened when the user leaves the submenu.
+
+ Returns:
+ new_menu (BuildingMenu): the new building menu or None.
+
+ """
+ parent_keys=parent_keysor[]
+ parents=list(self.parents)
+ parents.append((type(self).__module__+"."+type(self).__name__,self.obj,parent_keys))
+ ifself.caller.cmdset.has(BuildingMenuCmdSet):
+ self.caller.cmdset.remove(BuildingMenuCmdSet)
+
+ # Shift to the new menu
+ try:
+ menu_class=class_from_module(submenu_class)
+ exceptException:
+ log_trace(
+ "BuildingMenu: attempting to load class {} failed".format(repr(submenu_class))
+ )
+ return
+
+ # Create the submenu
+ try:
+ building_menu=menu_class(self.caller,submenu_obj,parents=parents)
+ exceptException:
+ log_trace(
+ "An error occurred while creating building menu {}".format(repr(submenu_class))
+ )
+ return
+ else:
+ returnbuilding_menu.open()
+
+
[docs]defmove(self,key=None,back=False,quiet=False,string=""):
+ """
+ Move inside the menu.
+
+ Args:
+ key (any): the portion of the key to add to the current
+ menu keys. If you wish to go back in the menu
+ tree, don't provide a `key`, just set `back` to `True`.
+ back (bool, optional): go back in the menu (`False` by default).
+ quiet (bool, optional): should the menu or choice be
+ displayed afterward?
+ string (str, optional): the string sent by the caller to move.
+
+ Note:
+ This method will need to be called directly should you
+ use more than two levels in your menu. For instance,
+ in your room menu, if you want to have an "exits"
+ option, and then be able to enter "north" in this
+ choice to edit an exit. The specific exit choice
+ could be a different menu (with a different class), but
+ it could also be an additional level in your original menu.
+ If that's the case, you will need to use this method.
+
+ """
+ choice=self.current_choice
+ ifchoice:
+ choice.leave("")
+
+ ifnotback:# Move forward
+ ifnotkey:
+ raiseValueError("you are asking to move forward, you should specify a key.")
+
+ self.keys.append(key)
+ else:# Move backward
+ ifnotself.keys:
+ raiseValueError(
+ "you already are at the top of the tree, you cannot move backward."
+ )
+
+ delself.keys[-1]
+
+ self._save()
+ choice=self.current_choice
+ ifchoice:
+ choice.enter(string)
+
+ ifnotquiet:
+ self.display()
+
+
[docs]defclose(self):
+ """Close the building menu, removing the CmdSet."""
+ ifself.caller.cmdset.has(BuildingMenuCmdSet):
+ self.caller.cmdset.delete(BuildingMenuCmdSet)
+ ifself.caller.attributes.has("_building_menu"):
+ self.caller.attributes.remove("_building_menu")
+ ifself.caller.nattributes.has("_building_menu"):
+ self.caller.nattributes.remove("_building_menu")
+
+ # Display methods. Override for customization
+
[docs]defdisplay_title(self):
+ """Return the menu title to be displayed."""
+ return_call_or_get(self.title,menu=self,obj=self.obj,caller=self.caller).format(
+ obj=self.obj
+ )
[docs]defdisplay(self):
+ """Display the entire menu or a single choice, depending on the keys."""
+ choice=self.current_choice
+ ifself.keysandchoice:
+ text=choice.format_text()
+ else:
+ text=self.display_title()+"\n"
+ forchoiceinself.relevant_choices:
+ text+="\n"+self.display_choice(choice)
+
+ self.caller.msg(text)
+
+
[docs]@staticmethod
+ defrestore(caller):
+ """Restore the building menu for the caller.
+
+ Args:
+ caller (Account or Object): the caller.
+
+ Note:
+ This method should be automatically called if a menu is
+ saved in the caller, but the object itself cannot be found.
+
+ """
+ menu=caller.db._building_menu
+ ifmenu:
+ class_name=menu.get("class")
+ ifnotclass_name:
+ log_err(
+ "BuildingMenu: on caller {}, a persistent attribute holds building menu "
+ "data, but no class could be found to restore the menu".format(caller)
+ )
+ return
+
+ try:
+ menu_class=class_from_module(class_name)
+ exceptException:
+ log_trace(
+ "BuildingMenu: attempting to load class {} failed".format(repr(class_name))
+ )
+ return
+
+ # Create the menu
+ obj=menu.get("obj")
+ keys=menu.get("keys")
+ title=menu.get("title","")
+ parents=menu.get("parents")
+ persistent=menu.get("persistent",False)
+ try:
+ building_menu=menu_class(
+ caller,obj,title=title,keys=keys,parents=parents,persistent=persistent
+ )
+ exceptException:
+ log_trace(
+ "An error occurred while creating building menu {}".format(repr(class_name))
+ )
+ return
+
+ returnbuilding_menu
+
+
+# Generic building menu and command
+
[docs]classGenericBuildingMenu(BuildingMenu):
+
+ """A generic building menu, allowing to edit any object.
+
+ This is more a demonstration menu. By default, it allows to edit the
+ object key and description. Nevertheless, it will be useful to demonstrate
+ how building menus are meant to be used.
+
+ """
+
+
[docs]definit(self,obj):
+ """Build the meny, adding the 'key' and 'description' choices.
+
+ Args:
+ obj (Object): any object to be edited, like a character or room.
+
+ Note:
+ The 'quit' choice will be automatically added, though you can
+ call `add_choice_quit` to add this choice with different options.
+
+ """
+ self.add_choice(
+ "key",
+ key="k",
+ attr="key",
+ glance="{obj.key}",
+ text="""
+ -------------------------------------------------------------------------------
+ Editing the key of {{obj.key}}(#{{obj.id}})
+
+ You can change the simply by entering it.
+ Use |y{back}|n to go back to the main menu.
+
+ Current key: |c{{obj.key}}|n
+ """.format(
+ back="|n or |y".join(self.keys_go_back)
+ ),
+ )
+ self.add_choice_edit("description",key="d",attr="db.desc")
+
+
+
[docs]classGenericBuildingCmd(Command):
+
+ """
+ Generic building command.
+
+ Syntax:
+ @edit [object]
+
+ Open a building menu to edit the specified object. This menu allows to
+ change the object's key and description.
+
+ Examples:
+ @edit here
+ @edit self
+ @edit #142
+
+ """
+
+ key="@edit"
+
+
[docs]deffunc(self):
+ ifnotself.args.strip():
+ self.msg("You should provide an argument to this function: the object to edit.")
+ return
+
+ obj=self.caller.search(self.args.strip(),global_search=True)
+ ifnotobj:
+ return
+
+ menu=GenericBuildingMenu(self.caller,obj)
+ menu.open()
+"""
+
+Contribution - Griatch 2011
+
+> Note - with the advent of MULTISESSION_MODE=2, this is not really as
+necessary anymore - the ooclook and @charcreate commands in that mode
+replaces this module with better functionality. This remains here for
+inspiration.
+
+This is a simple character creation commandset for the Account level.
+It shows some more info and gives the Account the option to create a
+character without any more customizations than their name (further
+options are unique for each game anyway).
+
+In MULTISESSION_MODEs 0 and 1, you will automatically log into an
+existing Character. When using `@ooc` you will then end up in this
+cmdset.
+
+Installation:
+
+Import this module to `mygame/commands/default_cmdsets.py` and
+add `chargen.OOCCMdSetCharGen` to the `AccountCmdSet` class
+(it says where to add it). Reload.
+
+"""
+
+fromdjango.confimportsettings
+fromevenniaimportCommand,create_object,utils
+fromevenniaimportdefault_cmds,managers
+
+CHARACTER_TYPECLASS=settings.BASE_CHARACTER_TYPECLASS
+
+
+
[docs]classCmdOOCLook(default_cmds.CmdLook):
+ """
+ ooc look
+
+ Usage:
+ look
+ look <character>
+
+ This is an OOC version of the look command. Since an Account doesn't
+ have an in-game existence, there is no concept of location or
+ "self".
+
+ If any characters are available for you to control, you may look
+ at them with this command.
+ """
+
+ key="look"
+ aliases=["l","ls"]
+ locks="cmd:all()"
+ help_category="General"
+
+
[docs]deffunc(self):
+ """
+ Implements the ooc look command
+
+ We use an attribute _character_dbrefs on the account in order
+ to figure out which characters are "theirs". A drawback of this
+ is that only the CmdCharacterCreate command adds this attribute,
+ and thus e.g. account #1 will not be listed (although it will work).
+ Existence in this list does not depend on puppeting rights though,
+ that is checked by the @ic command directly.
+ """
+
+ # making sure caller is really an account
+ self.character=None
+ ifutils.inherits_from(self.caller,"evennia.objects.objects.Object"):
+ # An object of some type is calling. Convert to account.
+ self.character=self.caller
+ ifhasattr(self.caller,"account"):
+ self.caller=self.caller.account
+
+ ifnotself.character:
+ # ooc mode, we are accounts
+
+ avail_chars=self.caller.db._character_dbrefs
+ ifself.args:
+ # Maybe the caller wants to look at a character
+ ifnotavail_chars:
+ self.caller.msg("You have no characters to look at. Why not create one?")
+ return
+ objs=managers.objects.get_objs_with_key_and_typeclass(
+ self.args.strip(),CHARACTER_TYPECLASS
+ )
+ objs=[objforobjinobjsifobj.idinavail_chars]
+ ifnotobjs:
+ self.caller.msg("You cannot see this Character.")
+ return
+ self.caller.msg(objs[0].return_appearance(self.caller))
+ return
+
+ # not inspecting a character. Show the OOC info.
+ charnames=[]
+ ifself.caller.db._character_dbrefs:
+ dbrefs=self.caller.db._character_dbrefs
+ charobjs=[managers.objects.get_id(dbref)fordbrefindbrefs]
+ charnames=[charobj.keyforcharobjincharobjsifcharobj]
+ ifcharnames:
+ charlist="The following Character(s) are available:\n\n"
+ charlist+="\n\r".join(["|w %s|n"%charnameforcharnameincharnames])
+ charlist+="\n\n Use |w@ic <character name>|n to switch to that Character."
+ else:
+ charlist="You have no Characters."
+ string=""" You, %s, are an |wOOC ghost|n without form. The world is hidden
+ from you and besides chatting on channels your options are limited.
+ You need to have a Character in order to interact with the world.
+
+%s
+
+ Use |wcreate <name>|n to create a new character and |whelp|n for a
+ list of available commands."""%(
+ self.caller.key,
+ charlist,
+ )
+ self.caller.msg(string)
+
+ else:
+ # not ooc mode - leave back to normal look
+ # we have to put this back for normal look to work.
+ self.caller=self.character
+ super().func()
+
+
+
[docs]classCmdOOCCharacterCreate(Command):
+ """
+ creates a character
+
+ Usage:
+ create <character name>
+
+ This will create a new character, assuming
+ the given character name does not already exist.
+ """
+
+ key="create"
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """
+ Tries to create the Character object. We also put an
+ attribute on ourselves to remember it.
+ """
+
+ # making sure caller is really an account
+ self.character=None
+ ifutils.inherits_from(self.caller,"evennia.objects.objects.Object"):
+ # An object of some type is calling. Convert to account.
+ self.character=self.caller
+ ifhasattr(self.caller,"account"):
+ self.caller=self.caller.account
+
+ ifnotself.args:
+ self.caller.msg("Usage: create <character name>")
+ return
+ charname=self.args.strip()
+ old_char=managers.objects.get_objs_with_key_and_typeclass(charname,CHARACTER_TYPECLASS)
+ ifold_char:
+ self.caller.msg("Character |c%s|n already exists."%charname)
+ return
+ # create the character
+
+ new_character=create_object(CHARACTER_TYPECLASS,key=charname)
+ ifnotnew_character:
+ self.caller.msg(
+ "|rThe Character couldn't be created. This is a bug. Please contact an admin."
+ )
+ return
+ # make sure to lock the character to only be puppeted by this account
+ new_character.locks.add(
+ "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)"
+ %(new_character.id,self.caller.id)
+ )
+
+ # save dbref
+ avail_chars=self.caller.db._character_dbrefs
+ ifavail_chars:
+ avail_chars.append(new_character.id)
+ else:
+ avail_chars=[new_character.id]
+ self.caller.db._character_dbrefs=avail_chars
+ self.caller.msg("|gThe character |c%s|g was successfully created!"%charname)
[docs]defat_cmdset_creation(self):
+ """Install everything from the default set, then overload"""
+ self.add(CmdOOCLook())
+ self.add(CmdOOCCharacterCreate())
+"""
+Clothing - Provides a typeclass and commands for wearable clothing,
+which is appended to a character's description when worn.
+
+Evennia contribution - Tim Ashley Jenkins 2017
+
+Clothing items, when worn, are added to the character's description
+in a list. For example, if wearing the following clothing items:
+
+ a thin and delicate necklace
+ a pair of regular ol' shoes
+ one nice hat
+ a very pretty dress
+
+A character's description may look like this:
+
+ Superuser(#1)
+ This is User #1.
+
+ Superuser is wearing one nice hat, a thin and delicate necklace,
+ a very pretty dress and a pair of regular ol' shoes.
+
+Characters can also specify the style of wear for their clothing - I.E.
+to wear a scarf 'tied into a tight knot around the neck' or 'draped
+loosely across the shoulders' - to add an easy avenue of customization.
+For example, after entering:
+
+ wear scarf draped loosely across the shoulders
+
+The garment appears like so in the description:
+
+ Superuser(#1)
+ This is User #1.
+
+ Superuser is wearing a fanciful-looking scarf draped loosely
+ across the shoulders.
+
+Items of clothing can be used to cover other items, and many options
+are provided to define your own clothing types and their limits and
+behaviors. For example, to have undergarments automatically covered
+by outerwear, or to put a limit on the number of each type of item
+that can be worn. The system as-is is fairly freeform - you
+can cover any garment with almost any other, for example - but it
+can easily be made more restrictive, and can even be tied into a
+system for armor or other equipment.
+
+To install, import this module and have your default character
+inherit from ClothedCharacter in your game's characters.py file:
+
+ from evennia.contrib.clothing import ClothedCharacter
+
+ class Character(ClothedCharacter):
+
+And then add ClothedCharacterCmdSet in your character set in your
+game's commands/default_cmdsets.py:
+
+ from evennia.contrib.clothing import ClothedCharacterCmdSet
+
+ class CharacterCmdSet(default_cmds.CharacterCmdSet):
+ ...
+ at_cmdset_creation(self):
+
+ super().at_cmdset_creation()
+ ...
+ self.add(ClothedCharacterCmdSet) # <-- add this
+
+From here, you can use the default builder commands to create clothes
+with which to test the system:
+
+ @create a pretty shirt : evennia.contrib.clothing.Clothing
+ @set shirt/clothing_type = 'top'
+ wear shirt
+
+"""
+
+fromevenniaimportDefaultObject
+fromevenniaimportDefaultCharacter
+fromevenniaimportdefault_cmds
+fromevennia.commands.default.muxcommandimportMuxCommand
+fromevennia.utilsimportlist_to_string
+fromevennia.utilsimportevtable
+
+# Options start here.
+# Maximum character length of 'wear style' strings, or None for unlimited.
+WEARSTYLE_MAXLENGTH=50
+
+# The rest of these options have to do with clothing types. Clothing types are optional,
+# but can be used to give better control over how different items of clothing behave. You
+# can freely add, remove, or change clothing types to suit the needs of your game and use
+# the options below to affect their behavior.
+
+# The order in which clothing types appear on the description. Untyped clothing or clothing
+# with a type not given in this list goes last.
+CLOTHING_TYPE_ORDER=[
+ "hat",
+ "jewelry",
+ "top",
+ "undershirt",
+ "gloves",
+ "fullbody",
+ "bottom",
+ "underpants",
+ "socks",
+ "shoes",
+ "accessory",
+]
+# The maximum number of each type of clothes that can be worn. Unlimited if untyped or not specified.
+CLOTHING_TYPE_LIMIT={"hat":1,"gloves":1,"socks":1,"shoes":1}
+# The maximum number of clothing items that can be worn, or None for unlimited.
+CLOTHING_OVERALL_LIMIT=20
+# What types of clothes will automatically cover what other types of clothes when worn.
+# Note that clothing only gets auto-covered if it's already worn when you put something
+# on that auto-covers it - for example, it's perfectly possible to have your underpants
+# showing if you put them on after your pants!
+CLOTHING_TYPE_AUTOCOVER={
+ "top":["undershirt"],
+ "bottom":["underpants"],
+ "fullbody":["undershirt","underpants"],
+ "shoes":["socks"],
+}
+# Types of clothes that can't be used to cover other clothes.
+CLOTHING_TYPE_CANT_COVER_WITH=["jewelry"]
+
+
+# HELPER FUNCTIONS START HERE
+
+
+
[docs]deforder_clothes_list(clothes_list):
+ """
+ Orders a given clothes list by the order specified in CLOTHING_TYPE_ORDER.
+
+ Args:
+ clothes_list (list): List of clothing items to put in order
+
+ Returns:
+ ordered_clothes_list (list): The same list as passed, but re-ordered
+ according to the hierarchy of clothing types
+ specified in CLOTHING_TYPE_ORDER.
+ """
+ ordered_clothes_list=clothes_list
+ # For each type of clothing that exists...
+ forcurrent_typeinreversed(CLOTHING_TYPE_ORDER):
+ # Check each item in the given clothes list.
+ forclothesinclothes_list:
+ # If the item has a clothing type...
+ ifclothes.db.clothing_type:
+ item_type=clothes.db.clothing_type
+ # And the clothing type matches the current type...
+ ifitem_type==current_type:
+ # Move it to the front of the list!
+ ordered_clothes_list.remove(clothes)
+ ordered_clothes_list.insert(0,clothes)
+ returnordered_clothes_list
+
+
+
[docs]defget_worn_clothes(character,exclude_covered=False):
+ """
+ Get a list of clothes worn by a given character.
+
+ Args:
+ character (obj): The character to get a list of worn clothes from.
+
+ Keyword Args:
+ exclude_covered (bool): If True, excludes clothes covered by other
+ clothing from the returned list.
+
+ Returns:
+ ordered_clothes_list (list): A list of clothing items worn by the
+ given character, ordered according to
+ the CLOTHING_TYPE_ORDER option specified
+ in this module.
+ """
+ clothes_list=[]
+ forthingincharacter.contents:
+ # If uncovered or not excluding covered items
+ ifnotthing.db.covered_byorexclude_coveredisFalse:
+ # If 'worn' is True, add to the list
+ ifthing.db.worn:
+ clothes_list.append(thing)
+ # Might as well put them in order here too.
+ ordered_clothes_list=order_clothes_list(clothes_list)
+ returnordered_clothes_list
+
+
+
[docs]defclothing_type_count(clothes_list):
+ """
+ Returns a dictionary of the number of each clothing type
+ in a given list of clothing objects.
+
+ Args:
+ clothes_list (list): A list of clothing items from which
+ to count the number of clothing types
+ represented among them.
+
+ Returns:
+ types_count (dict): A dictionary of clothing types represented
+ in the given list and the number of each
+ clothing type represented.
+ """
+ types_count={}
+ forgarmentinclothes_list:
+ ifgarment.db.clothing_type:
+ type=garment.db.clothing_type
+ iftypenotinlist(types_count.keys()):
+ types_count[type]=1
+ else:
+ types_count[type]+=1
+ returntypes_count
+
+
+
[docs]defsingle_type_count(clothes_list,type):
+ """
+ Returns an integer value of the number of a given type of clothing in a list.
+
+ Args:
+ clothes_list (list): List of clothing objects to count from
+ type (str): Clothing type to count
+
+ Returns:
+ type_count (int): Number of garments of the specified type in the given
+ list of clothing objects
+ """
+ type_count=0
+ forgarmentinclothes_list:
+ ifgarment.db.clothing_type:
+ ifgarment.db.clothing_type==type:
+ type_count+=1
+ returntype_count
[docs]defwear(self,wearer,wearstyle,quiet=False):
+ """
+ Sets clothes to 'worn' and optionally echoes to the room.
+
+ Args:
+ wearer (obj): character object wearing this clothing object
+ wearstyle (True or str): string describing the style of wear or True for none
+
+ Keyword Args:
+ quiet (bool): If false, does not message the room
+
+ Notes:
+ Optionally sets db.worn with a 'wearstyle' that appends a short passage to
+ the end of the name of the clothing to describe how it's worn that shows
+ up in the wearer's desc - I.E. 'around his neck' or 'tied loosely around
+ her waist'. If db.worn is set to 'True' then just the name will be shown.
+ """
+ # Set clothing as worn
+ self.db.worn=wearstyle
+ # Auto-cover appropirate clothing types, as specified above
+ to_cover=[]
+ ifself.db.clothing_typeandself.db.clothing_typeinCLOTHING_TYPE_AUTOCOVER:
+ forgarmentinget_worn_clothes(wearer):
+ if(
+ garment.db.clothing_type
+ andgarment.db.clothing_typeinCLOTHING_TYPE_AUTOCOVER[self.db.clothing_type]
+ ):
+ to_cover.append(garment)
+ garment.db.covered_by=self
+ # Return if quiet
+ ifquiet:
+ return
+ # Echo a message to the room
+ message="%s puts on %s"%(wearer,self.name)
+ ifwearstyleisnotTrue:
+ message="%s wears %s%s"%(wearer,self.name,wearstyle)
+ ifto_cover:
+ message=message+", covering %s"%list_to_string(to_cover)
+ wearer.location.msg_contents(message+".")
+
+
[docs]defremove(self,wearer,quiet=False):
+ """
+ Removes worn clothes and optionally echoes to the room.
+
+ Args:
+ wearer (obj): character object wearing this clothing object
+
+ Keyword Args:
+ quiet (bool): If false, does not message the room
+ """
+ self.db.worn=False
+ remove_message="%s removes %s."%(wearer,self.name)
+ uncovered_list=[]
+
+ # Check to see if any other clothes are covered by this object.
+ forthinginwearer.contents:
+ # If anything is covered by
+ ifthing.db.covered_by==self:
+ thing.db.covered_by=False
+ uncovered_list.append(thing.name)
+ iflen(uncovered_list)>0:
+ remove_message="%s removes %s, revealing %s."%(
+ wearer,
+ self.name,
+ list_to_string(uncovered_list),
+ )
+ # Echo a message to the room
+ ifnotquiet:
+ wearer.location.msg_contents(remove_message)
+
+
[docs]defat_get(self,getter):
+ """
+ Makes absolutely sure clothes aren't already set as 'worn'
+ when they're picked up, in case they've somehow had their
+ location changed without getting removed.
+ """
+ self.db.worn=False
+
+
+
[docs]classClothedCharacter(DefaultCharacter):
+ """
+ Character that displays worn clothing when looked at. You can also
+ just copy the return_appearance hook defined below to your own game's
+ character typeclass.
+ """
+
+
[docs]defreturn_appearance(self,looker):
+ """
+ This formats a description. It is the hook a 'look' command
+ should call.
+
+ Args:
+ looker (Object): Object doing the looking.
+
+ Notes:
+ The name of every clothing item carried and worn by the character
+ is appended to their description. If the clothing's db.worn value
+ is set to True, only the name is appended, but if the value is a
+ string, the string is appended to the end of the name, to allow
+ characters to specify how clothing is worn.
+ """
+ ifnotlooker:
+ return""
+ # get description, build string
+ string="|c%s|n\n"%self.get_display_name(looker)
+ desc=self.db.desc
+ worn_string_list=[]
+ clothes_list=get_worn_clothes(self,exclude_covered=True)
+ # Append worn, uncovered clothing to the description
+ forgarmentinclothes_list:
+ # If 'worn' is True, just append the name
+ ifgarment.db.wornisTrue:
+ worn_string_list.append(garment.name)
+ # Otherwise, append the name and the string value of 'worn'
+ elifgarment.db.worn:
+ worn_string_list.append("%s%s"%(garment.name,garment.db.worn))
+ ifdesc:
+ string+="%s"%desc
+ # Append worn clothes.
+ ifworn_string_list:
+ string+="|/|/%s is wearing %s."%(self,list_to_string(worn_string_list))
+ else:
+ string+="|/|/%s is not wearing anything."%self
+ returnstring
+
+
+# COMMANDS START HERE
+
+
+
[docs]classCmdWear(MuxCommand):
+ """
+ Puts on an item of clothing you are holding.
+
+ Usage:
+ wear <obj> [wear style]
+
+ Examples:
+ wear shirt
+ wear scarf wrapped loosely about the shoulders
+
+ All the clothes you are wearing are appended to your description.
+ If you provide a 'wear style' after the command, the message you
+ provide will be displayed after the clothing's name.
+ """
+
+ key="wear"
+ help_category="clothing"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotself.args:
+ self.caller.msg("Usage: wear <obj> [wear style]")
+ return
+ clothing=self.caller.search(self.arglist[0],candidates=self.caller.contents)
+ wearstyle=True
+ ifnotclothing:
+ self.caller.msg("Thing to wear must be in your inventory.")
+ return
+ ifnotclothing.is_typeclass("evennia.contrib.clothing.Clothing",exact=False):
+ self.caller.msg("That's not clothes!")
+ return
+
+ # Enforce overall clothing limit.
+ ifCLOTHING_OVERALL_LIMITandlen(get_worn_clothes(self.caller))>=CLOTHING_OVERALL_LIMIT:
+ self.caller.msg("You can't wear any more clothes.")
+ return
+
+ # Apply individual clothing type limits.
+ ifclothing.db.clothing_typeandnotclothing.db.worn:
+ type_count=single_type_count(get_worn_clothes(self.caller),clothing.db.clothing_type)
+ ifclothing.db.clothing_typeinlist(CLOTHING_TYPE_LIMIT.keys()):
+ iftype_count>=CLOTHING_TYPE_LIMIT[clothing.db.clothing_type]:
+ self.caller.msg(
+ "You can't wear any more clothes of the type '%s'."
+ %clothing.db.clothing_type
+ )
+ return
+
+ ifclothing.db.wornandlen(self.arglist)==1:
+ self.caller.msg("You're already wearing %s!"%clothing.name)
+ return
+ iflen(self.arglist)>1:# If wearstyle arguments given
+ wearstyle_list=self.arglist# Split arguments into a list of words
+ delwearstyle_list[0]# Leave first argument (the clothing item) out of the wearstyle
+ wearstring=" ".join(
+ str(e)foreinwearstyle_list
+ )# Join list of args back into one string
+ if(
+ WEARSTYLE_MAXLENGTHandlen(wearstring)>WEARSTYLE_MAXLENGTH
+ ):# If length of wearstyle exceeds limit
+ self.caller.msg(
+ "Please keep your wear style message to less than %i characters."
+ %WEARSTYLE_MAXLENGTH
+ )
+ else:
+ wearstyle=wearstring
+ clothing.wear(self.caller,wearstyle)
+
+
+
[docs]classCmdRemove(MuxCommand):
+ """
+ Takes off an item of clothing.
+
+ Usage:
+ remove <obj>
+
+ Removes an item of clothing you are wearing. You can't remove
+ clothes that are covered up by something else - you must take
+ off the covering item first.
+ """
+
+ key="remove"
+ help_category="clothing"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ clothing=self.caller.search(self.args,candidates=self.caller.contents)
+ ifnotclothing:
+ self.caller.msg("Thing to remove must be carried or worn.")
+ return
+ ifnotclothing.db.worn:
+ self.caller.msg("You're not wearing that!")
+ return
+ ifclothing.db.covered_by:
+ self.caller.msg("You have to take off %s first."%clothing.db.covered_by.name)
+ return
+ clothing.remove(self.caller)
+
+
+
[docs]classCmdCover(MuxCommand):
+ """
+ Covers a worn item of clothing with another you're holding or wearing.
+
+ Usage:
+ cover <obj> [with] <obj>
+
+ When you cover a clothing item, it is hidden and no longer appears in
+ your description until it's uncovered or the item covering it is removed.
+ You can't remove an item of clothing if it's covered.
+ """
+
+ key="cover"
+ help_category="clothing"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+
+ iflen(self.arglist)<2:
+ self.caller.msg("Usage: cover <worn clothing> [with] <clothing object>")
+ return
+ # Get rid of optional 'with' syntax
+ ifself.arglist[1].lower()=="with"andlen(self.arglist)>2:
+ delself.arglist[1]
+ to_cover=self.caller.search(self.arglist[0],candidates=self.caller.contents)
+ cover_with=self.caller.search(self.arglist[1],candidates=self.caller.contents)
+ ifnotto_coverornotcover_with:
+ return
+ ifnotto_cover.is_typeclass("evennia.contrib.clothing.Clothing",exact=False):
+ self.caller.msg("%s isn't clothes!"%to_cover.name)
+ return
+ ifnotcover_with.is_typeclass("evennia.contrib.clothing.Clothing",exact=False):
+ self.caller.msg("%s isn't clothes!"%cover_with.name)
+ return
+ ifcover_with.db.clothing_type:
+ ifcover_with.db.clothing_typeinCLOTHING_TYPE_CANT_COVER_WITH:
+ self.caller.msg("You can't cover anything with that!")
+ return
+ ifnotto_cover.db.worn:
+ self.caller.msg("You're not wearing %s!"%to_cover.name)
+ return
+ ifto_cover==cover_with:
+ self.caller.msg("You can't cover an item with itself!")
+ return
+ ifcover_with.db.covered_by:
+ self.caller.msg("%s is covered by something else!"%cover_with.name)
+ return
+ ifto_cover.db.covered_by:
+ self.caller.msg(
+ "%s is already covered by %s."%(cover_with.name,to_cover.db.covered_by.name)
+ )
+ return
+ ifnotcover_with.db.worn:
+ cover_with.wear(
+ self.caller,True
+ )# Put on the item to cover with if it's not on already
+ self.caller.location.msg_contents(
+ "%s covers %s with %s."%(self.caller,to_cover.name,cover_with.name)
+ )
+ to_cover.db.covered_by=cover_with
+
+
+
[docs]classCmdUncover(MuxCommand):
+ """
+ Reveals a worn item of clothing that's currently covered up.
+
+ Usage:
+ uncover <obj>
+
+ When you uncover an item of clothing, you allow it to appear in your
+ description without having to take off the garment that's currently
+ covering it. You can't uncover an item of clothing if the item covering
+ it is also covered by something else.
+ """
+
+ key="uncover"
+ help_category="clothing"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+
+ ifnotself.args:
+ self.caller.msg("Usage: uncover <worn clothing object>")
+ return
+
+ to_uncover=self.caller.search(self.args,candidates=self.caller.contents)
+ ifnotto_uncover:
+ return
+ ifnotto_uncover.db.worn:
+ self.caller.msg("You're not wearing %s!"%to_uncover.name)
+ return
+ ifnotto_uncover.db.covered_by:
+ self.caller.msg("%s isn't covered by anything!"%to_uncover.name)
+ return
+ covered_by=to_uncover.db.covered_by
+ ifcovered_by.db.covered_by:
+ self.caller.msg("%s is under too many layers to uncover."%(to_uncover.name))
+ return
+ self.caller.location.msg_contents("%s uncovers %s."%(self.caller,to_uncover.name))
+ to_uncover.db.covered_by=None
+
+
+
[docs]classCmdDrop(MuxCommand):
+ """
+ drop something
+
+ Usage:
+ drop <obj>
+
+ Lets you drop an object from your inventory into the
+ location you are currently in.
+ """
+
+ key="drop"
+ locks="cmd:all()"
+ arg_regex=r"\s|$"
+
+
[docs]deffunc(self):
+ """Implement command"""
+
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("Drop what?")
+ return
+
+ # Because the DROP command by definition looks for items
+ # in inventory, call the search function using location = caller
+ obj=caller.search(
+ self.args,
+ location=caller,
+ nofound_string="You aren't carrying %s."%self.args,
+ multimatch_string="You carry more than one %s:"%self.args,
+ )
+ ifnotobj:
+ return
+
+ # This part is new!
+ # You can't drop clothing items that are covered.
+ ifobj.db.covered_by:
+ caller.msg("You can't drop that because it's covered by %s."%obj.db.covered_by)
+ return
+ # Remove clothes if they're dropped.
+ ifobj.db.worn:
+ obj.remove(caller,quiet=True)
+
+ obj.move_to(caller.location,quiet=True)
+ caller.msg("You drop %s."%(obj.name,))
+ caller.location.msg_contents("%s drops %s."%(caller.name,obj.name),exclude=caller)
+ # Call the object script's at_drop() method.
+ obj.at_drop(caller)
+
+
+
[docs]classCmdGive(MuxCommand):
+ """
+ give away something to someone
+
+ Usage:
+ give <inventory obj> = <target>
+
+ Gives an items from your inventory to another character,
+ placing it in their inventory.
+ """
+
+ key="give"
+ locks="cmd:all()"
+ arg_regex=r"\s|$"
+
+
[docs]deffunc(self):
+ """Implement give"""
+
+ caller=self.caller
+ ifnotself.argsornotself.rhs:
+ caller.msg("Usage: give <inventory object> = <target>")
+ return
+ to_give=caller.search(
+ self.lhs,
+ location=caller,
+ nofound_string="You aren't carrying %s."%self.lhs,
+ multimatch_string="You carry more than one %s:"%self.lhs,
+ )
+ target=caller.search(self.rhs)
+ ifnot(to_giveandtarget):
+ return
+ iftarget==caller:
+ caller.msg("You keep %s to yourself."%to_give.key)
+ return
+ ifnotto_give.location==caller:
+ caller.msg("You are not holding %s."%to_give.key)
+ return
+ # This is new! Can't give away something that's worn.
+ ifto_give.db.covered_by:
+ caller.msg(
+ "You can't give that away because it's covered by %s."%to_give.db.covered_by
+ )
+ return
+ # Remove clothes if they're given.
+ ifto_give.db.worn:
+ to_give.remove(caller)
+ to_give.move_to(caller.location,quiet=True)
+ # give object
+ caller.msg("You give %s to %s."%(to_give.key,target.key))
+ to_give.move_to(target,quiet=True)
+ target.msg("%s gives you %s."%(caller.key,to_give.key))
+ # Call the object script's at_give() method.
+ to_give.at_give(caller,target)
+
+
+
[docs]classCmdInventory(MuxCommand):
+ """
+ view inventory
+
+ Usage:
+ inventory
+ inv
+
+ Shows your inventory.
+ """
+
+ # Alternate version of the inventory command which separates
+ # worn and carried items.
+
+ key="inventory"
+ aliases=["inv","i"]
+ locks="cmd:all()"
+ arg_regex=r"$"
+
+
[docs]classClothedCharacterCmdSet(default_cmds.CharacterCmdSet):
+ """
+ Command set for clothing, including new versions of 'give' and 'drop'
+ that take worn and covered clothing into account, as well as a new
+ version of 'inventory' that differentiates between carried and worn
+ items.
+ """
+
+ key="DefaultCharacter"
+
+
[docs]defat_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ super().at_cmdset_creation()
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(CmdWear())
+ self.add(CmdRemove())
+ self.add(CmdCover())
+ self.add(CmdUncover())
+ self.add(CmdGive())
+ self.add(CmdDrop())
+ self.add(CmdInventory())
+"""
+Custom gametime
+
+Contrib - Griatch 2017, vlgeoff 2017
+
+This implements the evennia.utils.gametime module but supporting
+a custom calendar for your game world. It allows for scheduling
+events to happen at given in-game times, taking this custom
+calendar into account.
+
+Usage:
+
+Use as the normal gametime module, that is by importing and using the
+helper functions in this module in your own code. The calendar can be
+customized by adding the `TIME_UNITS` dictionary to your settings
+file. This maps unit names to their length, expressed in the smallest
+unit. Here's the default as an example:
+
+ TIME_UNITS = {
+ "sec": 1,
+ "min": 60,
+ "hr": 60 * 60,
+ "hour": 60 * 60,
+ "day": 60 * 60 * 24,
+ "week": 60 * 60 * 24 * 7,
+ "month": 60 * 60 * 24 * 7 * 4,
+ "yr": 60 * 60 * 24 * 7 * 4 * 12,
+ "year": 60 * 60 * 24 * 7 * 4 * 12, }
+
+When using a custom calendar, these time unit names are used as kwargs to
+the converter functions in this module.
+
+"""
+
+# change these to fit your game world
+
+fromdjango.confimportsettings
+fromevenniaimportDefaultScript
+fromevennia.utils.createimportcreate_script
+fromevennia.utilsimportgametime
+
+# The game time speedup / slowdown relative real time
+TIMEFACTOR=settings.TIME_FACTOR
+
+# These are the unit names understood by the scheduler.
+# Each unit must be consistent and expressed in seconds.
+UNITS=getattr(
+ settings,
+ "TIME_UNITS",
+ {
+ # default custom calendar
+ "sec":1,
+ "min":60,
+ "hr":60*60,
+ "hour":60*60,
+ "day":60*60*24,
+ "week":60*60*24*7,
+ "month":60*60*24*7*4,
+ "yr":60*60*24*7*4*12,
+ "year":60*60*24*7*4*12,
+ },
+)
+
+
+
[docs]deftime_to_tuple(seconds,*divisors):
+ """
+ Helper function. Creates a tuple of even dividends given a range
+ of divisors.
+
+ Args:
+ seconds (int): Number of seconds to format
+ *divisors (int): a sequence of numbers of integer dividends. The
+ number of seconds will be integer-divided by the first number in
+ this sequence, the remainder will be divided with the second and
+ so on.
+ Returns:
+ time (tuple): This tuple has length len(*args)+1, with the
+ last element being the last remaining seconds not evenly
+ divided by the supplied dividends.
+
+ """
+ results=[]
+ seconds=int(seconds)
+ fordivisorindivisors:
+ results.append(seconds//divisor)
+ seconds%=divisor
+ results.append(seconds)
+ returntuple(results)
+
+
+
[docs]defgametime_to_realtime(format=False,**kwargs):
+ """
+ This method helps to figure out the real-world time it will take until an
+ in-game time has passed. E.g. if an event should take place a month later
+ in-game, you will be able to find the number of real-world seconds this
+ corresponds to (hint: Interval events deal with real life seconds).
+
+ Keyword Args:
+ format (bool): Formatting the output.
+ days, month etc (int): These are the names of time units that must
+ match the `settings.TIME_UNITS` dict keys.
+
+ Returns:
+ time (float or tuple): The realtime difference or the same
+ time split up into time units.
+
+ Example:
+ gametime_to_realtime(days=2) -> number of seconds in real life from
+ now after which 2 in-game days will have passed.
+
+ """
+ # Dynamically creates the list of units based on kwarg names and UNITs list
+ rtime=0
+ forname,valueinkwargs.items():
+ # Allow plural names (like mins instead of min)
+ ifnamenotinUNITSandname.endswith("s"):
+ name=name[:-1]
+
+ ifnamenotinUNITS:
+ raiseValueError("the unit {} isn't defined as a valid ""game time unit".format(name))
+ rtime+=value*UNITS[name]
+ rtime/=TIMEFACTOR
+ ifformat:
+ returntime_to_tuple(rtime,31536000,2628000,604800,86400,3600,60)
+ returnrtime
+
+
+
[docs]defrealtime_to_gametime(secs=0,mins=0,hrs=0,days=0,weeks=0,months=0,yrs=0,format=False):
+ """
+ This method calculates how much in-game time a real-world time
+ interval would correspond to. This is usually a lot less
+ interesting than the other way around.
+
+ Keyword Args:
+ times (int): The various components of the time.
+ format (bool): Formatting the output.
+
+ Returns:
+ time (float or tuple): The gametime difference or the same
+ time split up into time units.
+
+ Example:
+ realtime_to_gametime(days=2) -> number of game-world seconds
+
+ """
+ gtime=TIMEFACTOR*(
+ secs
+ +mins*60
+ +hrs*3600
+ +days*86400
+ +weeks*604800
+ +months*2628000
+ +yrs*31536000
+ )
+ ifformat:
+ units=sorted(set(UNITS.values()),reverse=True)
+ # Remove seconds from the tuple
+ delunits[-1]
+
+ returntime_to_tuple(gtime,*units)
+ returngtime
+
+
+
[docs]defcustom_gametime(absolute=False):
+ """
+ Return the custom game time as a tuple of units, as defined in settings.
+
+ Args:
+ absolute (bool, optional): return the relative or absolute time.
+
+ Returns:
+ The tuple describing the game time. The length of the tuple
+ is related to the number of unique units defined in the
+ settings. By default, the tuple would be (year, month,
+ week, day, hour, minute, second).
+
+ """
+ current=gametime.gametime(absolute=absolute)
+ units=sorted(set(UNITS.values()),reverse=True)
+ delunits[-1]
+ returntime_to_tuple(current,*units)
+
+
+
[docs]defreal_seconds_until(**kwargs):
+ """
+ Return the real seconds until game time.
+
+ If the game time is 5:00, TIME_FACTOR is set to 2 and you ask
+ the number of seconds until it's 5:10, then this function should
+ return 300 (5 minutes).
+
+ Args:
+ times (str: int): the time units.
+
+ Example:
+ real_seconds_until(hour=5, min=10, sec=0)
+
+ Returns:
+ The number of real seconds before the given game time is up.
+
+ """
+ current=gametime.gametime(absolute=True)
+ units=sorted(set(UNITS.values()),reverse=True)
+ # Remove seconds from the tuple
+ delunits[-1]
+ divisors=list(time_to_tuple(current,*units))
+
+ # For each keyword, add in the unit's
+ units.append(1)
+ higher_unit=None
+ forunit,valueinkwargs.items():
+ # Get the unit's index
+ ifunitnotinUNITS:
+ raiseValueError("unknown unit".format(unit))
+
+ seconds=UNITS[unit]
+ index=units.index(seconds)
+ divisors[index]=value
+ ifhigher_unitisNoneorhigher_unit>index:
+ higher_unit=index
+
+ # Check the projected time
+ # Note that it can be already passed (the given time may be in the past)
+ projected=0
+ fori,valueinenumerate(divisors):
+ seconds=units[i]
+ projected+=value*seconds
+
+ ifprojected<=current:
+ # The time is in the past, increase the higher unit
+ ifhigher_unit:
+ divisors[higher_unit-1]+=1
+ else:
+ divisors[0]+=1
+
+ # Get the projected time again
+ projected=0
+ fori,valueinenumerate(divisors):
+ seconds=units[i]
+ projected+=value*seconds
+
+ return(projected-current)/TIMEFACTOR
+
+
+
[docs]defschedule(callback,repeat=False,**kwargs):
+ """
+ Call the callback when the game time is up.
+
+ Args:
+ callback (function): The callback function that will be called. This
+ must be a top-level function since the script will be persistent.
+ repeat (bool, optional): Should the callback be called regularly?
+ day, month, etc (str: int): The time units to call the callback; should
+ match the keys of TIME_UNITS.
+
+ Returns:
+ script (Script): The created script.
+
+ Examples:
+ schedule(func, min=5, sec=0) # Will call next hour at :05.
+ schedule(func, hour=2, min=30, sec=0) # Will call the next day at 02:30.
+ Notes:
+ This function will setup a script that will be called when the
+ time corresponds to the game time. If the game is stopped for
+ more than a few seconds, the callback may be called with a
+ slight delay. If `repeat` is set to True, the callback will be
+ called again next time the game time matches the given time.
+ The time is given in units as keyword arguments.
+
+ """
+ seconds=real_seconds_until(**kwargs)
+ script=create_script(
+ "evennia.contrib.custom_gametime.GametimeScript",
+ key="GametimeScript",
+ desc="A timegame-sensitive script",
+ interval=seconds,
+ start_delay=True,
+ repeats=-1ifrepeatelse1,
+ )
+ script.db.callback=callback
+ script.db.gametime=kwargs
+ returnscript
+
+
+# Scripts dealing in gametime (use `schedule` to create it)
+
+
+
+"""
+Dice - rolls dice for roleplaying, in-game gambling or GM:ing
+
+Evennia contribution - Griatch 2012
+
+
+This module implements a full-fledged dice-roller and a 'dice' command
+to go with it. It uses standard RPG 'd'-syntax (e.g. 2d6 to roll two
+six-sided die) and also supports modifiers such as 3d6 + 5.
+
+One can also specify a standard Python operator in order to specify
+eventual target numbers and get results in a fair and guaranteed
+unbiased way. For example a GM could (using the dice command) from
+the start define the roll as 2d6 < 8 to show that a roll below 8 is
+required to succeed. The command will normally echo this result to all
+parties (although it also has options for hidden and secret rolls).
+
+
+Installation:
+
+To use in your code, just import the roll_dice function from this module.
+
+To use the dice/roll command, just import this module in your custom
+cmdset module and add the following line to the end of DefaultCmdSet's
+at_cmdset_creation():
+
+ self.add(dice.CmdDice())
+
+After a reload the dice (or roll) command will be available in-game.
+
+"""
+importre
+fromrandomimportrandint
+fromevenniaimportdefault_cmds,CmdSet
+
+
+
[docs]defroll_dice(dicenum,dicetype,modifier=None,conditional=None,return_tuple=False):
+ """
+ This is a standard dice roller.
+
+ Args:
+ dicenum (int): Number of dice to roll (the result to be added).
+ dicetype (int): Number of sides of the dice to be rolled.
+ modifier (tuple): A tuple `(operator, value)`, where operator is
+ one of `"+"`, `"-"`, `"/"` or `"*"`. The result of the dice
+ roll(s) will be modified by this value.
+ conditional (tuple): A tuple `(conditional, value)`, where
+ conditional is one of `"=="`,`"<"`,`">"`,`">="`,`"<=`" or "`!=`".
+ This allows the roller to directly return a result depending
+ on if the conditional was passed or not.
+ return_tuple (bool): Return a tuple with all individual roll
+ results or not.
+
+ Returns:
+ roll_result (int): The result of the roll + modifiers. This is the
+ default return.
+ condition_result (bool): A True/False value returned if `conditional`
+ is set but not `return_tuple`. This effectively hides the result
+ of the roll.
+ full_result (tuple): If, return_tuple` is `True`, instead
+ return a tuple `(result, outcome, diff, rolls)`. Here,
+ `result` is the normal result of the roll + modifiers.
+ `outcome` and `diff` are the boolean result of the roll and
+ absolute difference to the `conditional` input; they will
+ be will be `None` if `conditional` is not set. `rolls` is
+ itself a tuple holding all the individual rolls in the case of
+ multiple die-rolls.
+
+ Raises:
+ TypeError if non-supported modifiers or conditionals are given.
+
+ Notes:
+ All input numbers are converted to integers.
+
+ Examples:
+ print roll_dice(2, 6) # 2d6
+ <<< 7
+ print roll_dice(1, 100, ('+', 5) # 1d100 + 5
+ <<< 34
+ print roll_dice(1, 20, conditional=('<', 10) # let'say we roll 3
+ <<< True
+ print roll_dice(3, 10, return_tuple=True)
+ <<< (11, None, None, (2, 5, 4))
+ print roll_dice(2, 20, ('-', 2), conditional=('>=', 10), return_tuple=True)
+ <<< (8, False, 2, (4, 6)) # roll was 4 + 6 - 2 = 8
+
+ """
+ dicenum=int(dicenum)
+ dicetype=int(dicetype)
+
+ # roll all dice, remembering each roll
+ rolls=tuple([randint(1,dicetype)forrollinrange(dicenum)])
+ result=sum(rolls)
+
+ ifmodifier:
+ # make sure to check types well before eval
+ mod,modvalue=modifier
+ ifmodnotin("+","-","*","/"):
+ raiseTypeError("Non-supported dice modifier: %s"%mod)
+ modvalue=int(modvalue)# for safety
+ result=eval("%s%s%s"%(result,mod,modvalue))
+ outcome,diff=None,None
+ ifconditional:
+ # make sure to check types well before eval
+ cond,condvalue=conditional
+ ifcondnotin(">","<",">=","<=","!=","=="):
+ raiseTypeError("Non-supported dice result conditional: %s"%conditional)
+ condvalue=int(condvalue)# for safety
+ outcome=eval("%s%s%s"%(result,cond,condvalue))# True/False
+ diff=abs(result-condvalue)
+ ifreturn_tuple:
+ returnresult,outcome,diff,rolls
+ else:
+ ifconditional:
+ returnoutcome
+ else:
+ returnresult
[docs]classCmdDice(default_cmds.MuxCommand):
+ """
+ roll dice
+
+ Usage:
+ dice[/switch] <nr>d<sides> [modifier] [success condition]
+
+ Switch:
+ hidden - tell the room the roll is being done, but don't show the result
+ secret - don't inform the room about neither roll nor result
+
+ Examples:
+ dice 3d6 + 4
+ dice 1d100 - 2 < 50
+
+ This will roll the given number of dice with given sides and modifiers.
+ So e.g. 2d6 + 3 means to 'roll a 6-sided die 2 times and add the result,
+ then add 3 to the total'.
+ Accepted modifiers are +, -, * and /.
+ A success condition is given as normal Python conditionals
+ (<,>,<=,>=,==,!=). So e.g. 2d6 + 3 > 10 means that the roll will succeed
+ only if the final result is above 8. If a success condition is given, the
+ outcome (pass/fail) will be echoed along with how much it succeeded/failed
+ with. The hidden/secret switches will hide all or parts of the roll from
+ everyone but the person rolling.
+ """
+
+ key="dice"
+ aliases=["roll","@dice"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Mostly parsing for calling the dice roller function"""
+
+ ifnotself.args:
+ self.caller.msg("Usage: @dice <nr>d<sides> [modifier] [conditional]")
+ return
+ argstring="".join(str(arg)forarginself.args)
+
+ parts=[partforpartinRE_PARTS.split(self.args)ifpart]
+ len_parts=len(parts)
+ modifier=None
+ conditional=None
+
+ iflen_parts<3orparts[1]!="d":
+ self.caller.msg(
+ "You must specify the die roll(s) as <nr>d<sides>."
+ " For example, 2d6 means rolling a 6-sided die 2 times."
+ )
+ return
+
+ # Limit the number of dice and sides a character can roll to prevent server slow down and crashes
+ ndicelimit=10000# Maximum number of dice
+ nsidelimit=10000# Maximum number of sides
+ ifint(parts[0])>ndicelimitorint(parts[2])>nsidelimit:
+ self.caller.msg("The maximum roll allowed is %sd%s."%(ndicelimit,nsidelimit))
+ return
+
+ ndice,nsides=parts[0],parts[2]
+ iflen_parts==3:
+ # just something like 1d6
+ pass
+ eliflen_parts==5:
+ # either e.g. 1d6 + 3 or something like 1d6 > 3
+ ifparts[3]in("+","-","*","/"):
+ modifier=(parts[3],parts[4])
+ else:# assume it is a conditional
+ conditional=(parts[3],parts[4])
+ eliflen_parts==7:
+ # the whole sequence, e.g. 1d6 + 3 > 5
+ modifier=(parts[3],parts[4])
+ conditional=(parts[5],parts[6])
+ else:
+ # error
+ self.caller.msg("You must specify a valid die roll")
+ return
+ # do the roll
+ try:
+ result,outcome,diff,rolls=roll_dice(
+ ndice,nsides,modifier=modifier,conditional=conditional,return_tuple=True
+ )
+ exceptValueError:
+ self.caller.msg(
+ "You need to enter valid integer numbers, modifiers and operators."
+ " |w%s|n was not understood."%self.args
+ )
+ return
+ # format output
+ iflen(rolls)>1:
+ rolls=", ".join(str(roll)forrollinrolls[:-1])+" and "+str(rolls[-1])
+ else:
+ rolls=rolls[0]
+ ifoutcomeisNone:
+ outcomestring=""
+ elifoutcome:
+ outcomestring=" This is a |gsuccess|n (by %s)."%diff
+ else:
+ outcomestring=" This is a |rfailure|n (by %s)."%diff
+ yourollstring="You roll %s%s."
+ roomrollstring="%s rolls %s%s."
+ resultstring=" Roll(s): %s. Total result is |w%s|n."
+
+ if"secret"inself.switches:
+ # don't echo to the room at all
+ string=yourollstring%(argstring," (secret, not echoed)")
+ string+="\n"+resultstring%(rolls,result)
+ string+=outcomestring+" (not echoed)"
+ self.caller.msg(string)
+ elif"hidden"inself.switches:
+ # announce the roll to the room, result only to caller
+ string=yourollstring%(argstring," (hidden)")
+ self.caller.msg(string)
+ string=roomrollstring%(self.caller.key,argstring," (hidden)")
+ self.caller.location.msg_contents(string,exclude=self.caller)
+ # handle result
+ string=resultstring%(rolls,result)
+ string+=outcomestring+" (not echoed)"
+ self.caller.msg(string)
+ else:
+ # normal roll
+ string=yourollstring%(argstring,"")
+ self.caller.msg(string)
+ string=roomrollstring%(self.caller.key,argstring,"")
+ self.caller.location.msg_contents(string,exclude=self.caller)
+ string=resultstring%(rolls,result)
+ string+=outcomestring
+ self.caller.location.msg_contents(string)
+
+
+
[docs]classDiceCmdSet(CmdSet):
+ """
+ a small cmdset for testing purposes.
+ Add with @py self.cmdset.add("contrib.dice.DiceCmdSet")
+ """
+
+
[docs]defat_cmdset_creation(self):
+ """Called when set is created"""
+ self.add(CmdDice())
+"""
+Email-based login system
+
+Evennia contrib - Griatch 2012
+
+
+This is a variant of the login system that requires an email-address
+instead of a username to login.
+
+This used to be the default Evennia login before replacing it with a
+more standard username + password system (having to supply an email
+for some reason caused a lot of confusion when people wanted to expand
+on it. The email is not strictly needed internally, nor is any
+confirmation email sent out anyway).
+
+
+Installation is simple:
+
+To your settings file, add/edit the line:
+
+```python
+CMDSET_UNLOGGEDIN = "contrib.email_login.UnloggedinCmdSet"
+```
+
+That's it. Reload the server and try to log in to see it.
+
+The initial login "graphic" will still not mention email addresses
+after this change. The login splashscreen is taken from strings in
+the module given by settings.CONNECTION_SCREEN_MODULE.
+
+"""
+importre
+fromdjango.confimportsettings
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.objects.modelsimportObjectDB
+fromevennia.server.modelsimportServerConfig
+
+fromevennia.commands.cmdsetimportCmdSet
+fromevennia.utilsimportlogger,utils,ansi
+fromevennia.commands.default.muxcommandimportMuxCommand
+fromevennia.commands.cmdhandlerimportCMD_LOGINSTART
+fromevennia.commands.defaultimport(
+ unloggedinasdefault_unloggedin,
+)# Used in CmdUnconnectedCreate
+
+# limit symbol import for API
+__all__=(
+ "CmdUnconnectedConnect",
+ "CmdUnconnectedCreate",
+ "CmdUnconnectedQuit",
+ "CmdUnconnectedLook",
+ "CmdUnconnectedHelp",
+)
+
+MULTISESSION_MODE=settings.MULTISESSION_MODE
+CONNECTION_SCREEN_MODULE=settings.CONNECTION_SCREEN_MODULE
+CONNECTION_SCREEN=""
+try:
+ CONNECTION_SCREEN=ansi.parse_ansi(utils.random_string_from_module(CONNECTION_SCREEN_MODULE))
+exceptException:
+ # malformed connection screen or no screen given
+ pass
+ifnotCONNECTION_SCREEN:
+ CONNECTION_SCREEN=(
+ "\nEvennia: Error in CONNECTION_SCREEN MODULE"
+ " (randomly picked connection screen variable is not a string). \nEnter 'help' for aid."
+ )
+
+
+
[docs]classCmdUnconnectedConnect(MuxCommand):
+ """
+ Connect to the game.
+
+ Usage (at login screen):
+ connect <email> <password>
+
+ Use the create command to first create an account before logging in.
+ """
+
+ key="connect"
+ aliases=["conn","con","co"]
+ locks="cmd:all()"# not really needed
+
+
[docs]deffunc(self):
+ """
+ Uses the Django admin api. Note that unlogged-in commands
+ have a unique position in that their `func()` receives
+ a session object instead of a `source_object` like all
+ other types of logged-in commands (this is because
+ there is no object yet before the account has logged in)
+ """
+
+ session=self.caller
+ arglist=self.arglist
+
+ ifnotarglistorlen(arglist)<2:
+ session.msg("\n\r Usage (without <>): connect <email> <password>")
+ return
+ email=arglist[0]
+ password=arglist[1]
+
+ # Match an email address to an account.
+ account=AccountDB.objects.get_account_from_email(email)
+ # No accountname match
+ ifnotaccount:
+ string="The email '%s' does not match any accounts."%email
+ string+="\n\r\n\rIf you are new you should first create a new account "
+ string+="using the 'create' command."
+ session.msg(string)
+ return
+ # We have at least one result, so we can check the password.
+ ifnotaccount[0].check_password(password):
+ session.msg("Incorrect password.")
+ return
+
+ # Check IP and/or name bans
+ bans=ServerConfig.objects.conf("server_bans")
+ ifbansand(
+ any(tup[0]==account.namefortupinbans)
+ orany(tup[2].match(session.address[0])fortupinbansiftup[2])
+ ):
+ # this is a banned IP or name!
+ string="|rYou have been banned and cannot continue from here."
+ string+="\nIf you feel this ban is in error, please email an admin.|x"
+ session.msg(string)
+ session.execute_cmd("quit")
+ return
+
+ # actually do the login. This will call all hooks.
+ session.sessionhandler.login(session,account)
+
+
+
[docs]classCmdUnconnectedCreate(MuxCommand):
+ """
+ Create a new account.
+
+ Usage (at login screen):
+ create \"accountname\" <email> <password>
+
+ This creates a new account account.
+
+ """
+
+ key="create"
+ aliases=["cre","cr"]
+ locks="cmd:all()"
+
+
[docs]defparse(self):
+ """
+ The parser must handle the multiple-word account
+ name enclosed in quotes:
+ connect "Long name with many words" my@myserv.com mypassw
+ """
+ super().parse()
+
+ self.accountinfo=[]
+ iflen(self.arglist)<3:
+ return
+ iflen(self.arglist)>3:
+ # this means we have a multi_word accountname. pop from the back.
+ password=self.arglist.pop()
+ email=self.arglist.pop()
+ # what remains is the accountname.
+ accountname=" ".join(self.arglist)
+ else:
+ accountname,email,password=self.arglist
+
+ accountname=accountname.replace('"',"")# remove "
+ accountname=accountname.replace("'","")
+ self.accountinfo=(accountname,email,password)
+
+
[docs]deffunc(self):
+ """Do checks and create account"""
+
+ session=self.caller
+ try:
+ accountname,email,password=self.accountinfo
+ exceptValueError:
+ string='\n\r Usage (without <>): create "<accountname>" <email> <password>'
+ session.msg(string)
+ return
+ ifnotemailornotpassword:
+ session.msg("\n\r You have to supply an e-mail address followed by a password.")
+ return
+ ifnotutils.validate_email_address(email):
+ # check so the email at least looks ok.
+ session.msg("'%s' is not a valid e-mail address."%email)
+ return
+ # sanity checks
+ ifnotre.findall(r"^[\w. @+\-']+$",accountname)ornot(0<len(accountname)<=30):
+ # this echoes the restrictions made by django's auth
+ # module (except not allowing spaces, for convenience of
+ # logging in).
+ string="\n\r Accountname can max be 30 characters or fewer. Letters, spaces, digits and @/./+/-/_/' only."
+ session.msg(string)
+ return
+ # strip excessive spaces in accountname
+ accountname=re.sub(r"\s+"," ",accountname).strip()
+ ifAccountDB.objects.filter(username__iexact=accountname):
+ # account already exists (we also ignore capitalization here)
+ session.msg("Sorry, there is already an account with the name '%s'."%accountname)
+ return
+ ifAccountDB.objects.get_account_from_email(email):
+ # email already set on an account
+ session.msg("Sorry, there is already an account with that email address.")
+ return
+ # Reserve accountnames found in GUEST_LIST
+ ifsettings.GUEST_LISTandaccountname.lower()in(
+ guest.lower()forguestinsettings.GUEST_LIST
+ ):
+ string="\n\r That name is reserved. Please choose another Accountname."
+ session.msg(string)
+ return
+ ifnotre.findall(r"^[\w. @+\-']+$",password)ornot(3<len(password)):
+ string=(
+ "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only."
+ "\nFor best security, make it longer than 8 characters. You can also use a phrase of"
+ "\nmany words if you enclose the password in double quotes."
+ )
+ session.msg(string)
+ return
+
+ # Check IP and/or name bans
+ bans=ServerConfig.objects.conf("server_bans")
+ ifbansand(
+ any(tup[0]==accountname.lower()fortupinbans)
+ orany(tup[2].match(session.address)fortupinbansiftup[2])
+ ):
+ # this is a banned IP or name!
+ string=(
+ "|rYou have been banned and cannot continue from here."
+ "\nIf you feel this ban is in error, please email an admin.|x"
+ )
+ session.msg(string)
+ session.sessionhandler.disconnect(session,"Good bye! Disconnecting.")
+ return
+
+ # everything's ok. Create the new player account.
+ try:
+ permissions=settings.PERMISSION_ACCOUNT_DEFAULT
+ typeclass=settings.BASE_CHARACTER_TYPECLASS
+ new_account=default_unloggedin._create_account(
+ session,accountname,password,permissions,email=email
+ )
+ ifnew_account:
+ ifMULTISESSION_MODE<2:
+ default_home=ObjectDB.objects.get_id(settings.DEFAULT_HOME)
+ default_unloggedin._create_character(
+ session,new_account,typeclass,default_home,permissions
+ )
+ # tell the caller everything went well.
+ string="A new account '%s' was created. Welcome!"
+ if" "inaccountname:
+ string+=(
+ "\n\nYou can now log in with the command 'connect \"%s\" <your password>'."
+ )
+ else:
+ string+="\n\nYou can now log with the command 'connect %s <your password>'."
+ session.msg(string%(accountname,email))
+
+ exceptException:
+ # We are in the middle between logged in and -not, so we have
+ # to handle tracebacks ourselves at this point. If we don't,
+ # we won't see any errors at all.
+ session.msg("An error occurred. Please e-mail an admin if the problem persists.")
+ logger.log_trace()
+ raise
+
+
+
[docs]classCmdUnconnectedQuit(MuxCommand):
+ """
+ We maintain a different version of the `quit` command
+ here for unconnected accounts for the sake of simplicity. The logged in
+ version is a bit more complicated.
+ """
+
+ key="quit"
+ aliases=["q","qu"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Simply close the connection."""
+ session=self.caller
+ session.sessionhandler.disconnect(session,"Good bye! Disconnecting.")
+
+
+
[docs]classCmdUnconnectedLook(MuxCommand):
+ """
+ This is an unconnected version of the `look` command for simplicity.
+
+ This is called by the server and kicks everything in gear.
+ All it does is display the connect screen.
+ """
+
+ key=CMD_LOGINSTART
+ aliases=["look","l"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Show the connect screen."""
+ self.caller.msg(CONNECTION_SCREEN)
+
+
+
[docs]classCmdUnconnectedHelp(MuxCommand):
+ """
+ This is an unconnected version of the help command,
+ for simplicity. It shows a pane of info.
+ """
+
+ key="help"
+ aliases=["h","?"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Shows help"""
+
+ string="""
+You are not yet logged into the game. Commands available at this point:
+ |wcreate, connect, look, help, quit|n
+
+To login to the system, you need to do one of the following:
+
+|w1)|n If you have no previous account, you need to use the 'create'
+ command like this:
+
+ |wcreate "Anna the Barbarian" anna@myemail.com c67jHL8p|n
+
+ It's always a good idea (not only here, but everywhere on the net)
+ to not use a regular word for your password. Make it longer than
+ 3 characters (ideally 6 or more) and mix numbers and capitalization
+ into it.
+
+|w2)|n If you have an account already, either because you just created
+ one in |w1)|n above or you are returning, use the 'connect' command:
+
+ |wconnect anna@myemail.com c67jHL8p|n
+
+ This should log you in. Run |whelp|n again once you're logged in
+ to get more aid. Hope you enjoy your stay!
+
+You can use the |wlook|n command if you want to see the connect screen again.
+"""
+ self.caller.msg(string)
+
+
+# command set for the mux-like login
+
+
+classUnloggedinCmdSet(CmdSet):
+ """
+ Sets up the unlogged cmdset.
+ """
+
+ key="Unloggedin"
+ priority=0
+
+ defat_cmdset_creation(self):
+ """Populate the cmdset"""
+ self.add(CmdUnconnectedConnect())
+ self.add(CmdUnconnectedCreate())
+ self.add(CmdUnconnectedQuit())
+ self.add(CmdUnconnectedLook())
+ self.add(CmdUnconnectedHelp())
+
+"""
+Extended Room
+
+Evennia Contribution - Griatch 2012, vincent-lg 2019
+
+This is an extended Room typeclass for Evennia. It is supported
+by an extended `Look` command and an extended `desc` command, also
+in this module.
+
+
+Features:
+
+1) Time-changing description slots
+
+This allows to change the full description text the room shows
+depending on larger time variations. Four seasons (spring, summer,
+autumn and winter) are used by default. The season is calculated
+on-demand (no Script or timer needed) and updates the full text block.
+
+There is also a general description which is used as fallback if
+one or more of the seasonal descriptions are not set when their
+time comes.
+
+An updated `desc` command allows for setting seasonal descriptions.
+
+The room uses the `evennia.utils.gametime.GameTime` global script. This is
+started by default, but if you have deactivated it, you need to
+supply your own time keeping mechanism.
+
+
+2) In-description changing tags
+
+Within each seasonal (or general) description text, you can also embed
+time-of-day dependent sections. Text inside such a tag will only show
+during that particular time of day. The tags looks like `<timeslot> ...
+</timeslot>`. By default there are four timeslots per day - morning,
+afternoon, evening and night.
+
+
+3) Details
+
+The Extended Room can be "detailed" with special keywords. This makes
+use of a special `Look` command. Details are "virtual" targets to look
+at, without there having to be a database object created for it. The
+Details are simply stored in a dictionary on the room and if the look
+command cannot find an object match for a `look <target>` command it
+will also look through the available details at the current location
+if applicable. The `@detail` command is used to change details.
+
+
+4) Extra commands
+
+ CmdExtendedRoomLook - look command supporting room details
+ CmdExtendedRoomDesc - desc command allowing to add seasonal descs,
+ CmdExtendedRoomDetail - command allowing to manipulate details in this room
+ as well as listing them
+ CmdExtendedRoomGameTime - A simple `time` command, displaying the current
+ time and season.
+
+
+Installation/testing:
+
+Adding the `ExtendedRoomCmdset` to the default character cmdset will add all
+new commands for use.
+
+In more detail, in mygame/commands/default_cmdsets.py:
+
+```
+...
+from evennia.contrib import extended_room # <-new
+
+class CharacterCmdset(default_cmds.Character_CmdSet):
+ ...
+ def at_cmdset_creation(self):
+ ...
+ self.add(extended_room.ExtendedRoomCmdSet) # <-new
+
+```
+
+Then reload to make the bew commands available. Note that they only work
+on rooms with the typeclass `ExtendedRoom`. Create new rooms with the right
+typeclass or use the `typeclass` command to swap existing rooms.
+
+"""
+
+
+importdatetime
+importre
+fromdjango.confimportsettings
+fromevenniaimportDefaultRoom
+fromevenniaimportgametime
+fromevenniaimportdefault_cmds
+fromevenniaimportutils
+fromevenniaimportCmdSet
+
+# error return function, needed by Extended Look command
+_AT_SEARCH_RESULT=utils.variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".",1))
+
+# regexes for in-desc replacements
+RE_MORNING=re.compile(r"<morning>(.*?)</morning>",re.IGNORECASE)
+RE_AFTERNOON=re.compile(r"<afternoon>(.*?)</afternoon>",re.IGNORECASE)
+RE_EVENING=re.compile(r"<evening>(.*?)</evening>",re.IGNORECASE)
+RE_NIGHT=re.compile(r"<night>(.*?)</night>",re.IGNORECASE)
+# this map is just a faster way to select the right regexes (the first
+# regex in each tuple will be parsed, the following will always be weeded out)
+REGEXMAP={
+ "morning":(RE_MORNING,RE_AFTERNOON,RE_EVENING,RE_NIGHT),
+ "afternoon":(RE_AFTERNOON,RE_MORNING,RE_EVENING,RE_NIGHT),
+ "evening":(RE_EVENING,RE_MORNING,RE_AFTERNOON,RE_NIGHT),
+ "night":(RE_NIGHT,RE_MORNING,RE_AFTERNOON,RE_EVENING),
+}
+
+# set up the seasons and time slots. This assumes gametime started at the
+# beginning of the year (so month 1 is equivalent to January), and that
+# one CAN divide the game's year into four seasons in the first place ...
+MONTHS_PER_YEAR=12
+SEASONAL_BOUNDARIES=(3/12.0,6/12.0,9/12.0)
+HOURS_PER_DAY=24
+DAY_BOUNDARIES=(0,6/24.0,12/24.0,18/24.0)
+
+
+# implements the Extended Room
+
+
+
[docs]classExtendedRoom(DefaultRoom):
+ """
+ This room implements a more advanced `look` functionality depending on
+ time. It also allows for "details", together with a slightly modified
+ look command.
+ """
+
+
[docs]defat_object_creation(self):
+ """Called when room is first created only."""
+ self.db.spring_desc=""
+ self.db.summer_desc=""
+ self.db.autumn_desc=""
+ self.db.winter_desc=""
+ # the general desc is used as a fallback if a seasonal one is not set
+ self.db.general_desc=""
+ # will be set dynamically. Can contain raw timeslot codes
+ self.db.raw_desc=""
+ # this will be set dynamically at first look. Parsed for timeslot codes
+ self.db.desc=""
+ # these will be filled later
+ self.ndb.last_season=None
+ self.ndb.last_timeslot=None
+ # detail storage
+ self.db.details={}
+
+
[docs]defget_time_and_season(self):
+ """
+ Calculate the current time and season ids.
+ """
+ # get the current time as parts of year and parts of day.
+ # we assume a standard calendar here and use 24h format.
+ timestamp=gametime.gametime(absolute=True)
+ # note that fromtimestamp includes the effects of server time zone!
+ datestamp=datetime.datetime.fromtimestamp(timestamp)
+ season=float(datestamp.month)/MONTHS_PER_YEAR
+ timeslot=float(datestamp.hour)/HOURS_PER_DAY
+
+ # figure out which slots these represent
+ ifSEASONAL_BOUNDARIES[0]<=season<SEASONAL_BOUNDARIES[1]:
+ curr_season="spring"
+ elifSEASONAL_BOUNDARIES[1]<=season<SEASONAL_BOUNDARIES[2]:
+ curr_season="summer"
+ elifSEASONAL_BOUNDARIES[2]<=season<1.0+SEASONAL_BOUNDARIES[0]:
+ curr_season="autumn"
+ else:
+ curr_season="winter"
+
+ ifDAY_BOUNDARIES[0]<=timeslot<DAY_BOUNDARIES[1]:
+ curr_timeslot="night"
+ elifDAY_BOUNDARIES[1]<=timeslot<DAY_BOUNDARIES[2]:
+ curr_timeslot="morning"
+ elifDAY_BOUNDARIES[2]<=timeslot<DAY_BOUNDARIES[3]:
+ curr_timeslot="afternoon"
+ else:
+ curr_timeslot="evening"
+
+ returncurr_season,curr_timeslot
+
+
[docs]defreplace_timeslots(self,raw_desc,curr_time):
+ """
+ Filter so that only time markers `<timeslot>...</timeslot>` of
+ the correct timeslot remains in the description.
+
+ Args:
+ raw_desc (str): The unmodified description.
+ curr_time (str): A timeslot identifier.
+
+ Returns:
+ description (str): A possibly moified description.
+
+ """
+ ifraw_desc:
+ regextuple=REGEXMAP[curr_time]
+ raw_desc=regextuple[0].sub(r"\1",raw_desc)
+ raw_desc=regextuple[1].sub("",raw_desc)
+ raw_desc=regextuple[2].sub("",raw_desc)
+ returnregextuple[3].sub("",raw_desc)
+ returnraw_desc
+
+
[docs]defreturn_detail(self,key):
+ """
+ This will attempt to match a "detail" to look for in the room.
+
+ Args:
+ key (str): A detail identifier.
+
+ Returns:
+ detail (str or None): A detail matching the given key.
+
+ Notes:
+ A detail is a way to offer more things to look at in a room
+ without having to add new objects. For this to work, we
+ require a custom `look` command that allows for `look
+ <detail>` - the look command should defer to this method on
+ the current location (if it exists) before giving up on
+ finding the target.
+
+ Details are not season-sensitive, but are parsed for timeslot
+ markers.
+ """
+ try:
+ detail=self.db.details.get(key.lower(),None)
+ exceptAttributeError:
+ # this happens if no attribute details is set at all
+ returnNone
+ ifdetail:
+ season,timeslot=self.get_time_and_season()
+ detail=self.replace_timeslots(detail,timeslot)
+ returndetail
+ returnNone
+
+
[docs]defset_detail(self,detailkey,description):
+ """
+ This sets a new detail, using an Attribute "details".
+
+ Args:
+ detailkey (str): The detail identifier to add (for
+ aliases you need to add multiple keys to the
+ same description). Case-insensitive.
+ description (str): The text to return when looking
+ at the given detailkey.
+
+ """
+ ifself.db.details:
+ self.db.details[detailkey.lower()]=description
+ else:
+ self.db.details={detailkey.lower():description}
+
+
[docs]defdel_detail(self,detailkey,description):
+ """
+ Delete a detail.
+
+ The description is ignored.
+
+ Args:
+ detailkey (str): the detail to remove (case-insensitive).
+ description (str, ignored): the description.
+
+ The description is only included for compliance but is completely
+ ignored. Note that this method doesn't raise any exception if
+ the detail doesn't exist in this room.
+
+ """
+ ifself.db.detailsanddetailkey.lower()inself.db.details:
+ delself.db.details[detailkey.lower()]
+
+
[docs]defreturn_appearance(self,looker,**kwargs):
+ """
+ This is called when e.g. the look command wants to retrieve
+ the description of this object.
+
+ Args:
+ looker (Object): The object looking at us.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ description (str): Our description.
+
+ """
+ # ensures that our description is current based on time/season
+ self.update_current_description()
+ # run the normal return_appearance method, now that desc is updated.
+ returnsuper(ExtendedRoom,self).return_appearance(looker,**kwargs)
+
+
[docs]defupdate_current_description(self):
+ """
+ This will update the description of the room if the time or season
+ has changed since last checked.
+ """
+ update=False
+ # get current time and season
+ curr_season,curr_timeslot=self.get_time_and_season()
+ # compare with previously stored slots
+ last_season=self.ndb.last_season
+ last_timeslot=self.ndb.last_timeslot
+ ifcurr_season!=last_season:
+ # season changed. Load new desc, or a fallback.
+ new_raw_desc=self.attributes.get("%s_desc"%curr_season)
+ ifnew_raw_desc:
+ raw_desc=new_raw_desc
+ else:
+ # no seasonal desc set. Use fallback
+ raw_desc=self.db.general_descorself.db.desc
+ self.db.raw_desc=raw_desc
+ self.ndb.last_season=curr_season
+ update=True
+ ifcurr_timeslot!=last_timeslot:
+ # timeslot changed. Set update flag.
+ self.ndb.last_timeslot=curr_timeslot
+ update=True
+ ifupdate:
+ # if anything changed we have to re-parse
+ # the raw_desc for time markers
+ # and re-save the description again.
+ self.db.desc=self.replace_timeslots(self.db.raw_desc,curr_timeslot)
+
+
+# Custom Look command supporting Room details. Add this to
+# the Default cmdset to use.
+
+
+
[docs]classCmdExtendedRoomLook(default_cmds.CmdLook):
+ """
+ look
+
+ Usage:
+ look
+ look <obj>
+ look <room detail>
+ look *<account>
+
+ Observes your location, details at your location or objects in your vicinity.
+ """
+
+
[docs]deffunc(self):
+ """
+ Handle the looking - add fallback to details.
+ """
+ caller=self.caller
+ args=self.args
+ ifargs:
+ looking_at_obj=caller.search(
+ args,
+ candidates=caller.location.contents+caller.contents,
+ use_nicks=True,
+ quiet=True,
+ )
+ ifnotlooking_at_obj:
+ # no object found. Check if there is a matching
+ # detail at location.
+ location=caller.location
+ if(
+ location
+ andhasattr(location,"return_detail")
+ andcallable(location.return_detail)
+ ):
+ detail=location.return_detail(args)
+ ifdetail:
+ # we found a detail instead. Show that.
+ caller.msg(detail)
+ return
+ # no detail found. Trigger delayed error messages
+ _AT_SEARCH_RESULT(looking_at_obj,caller,args,quiet=False)
+ return
+ else:
+ # we need to extract the match manually.
+ looking_at_obj=utils.make_iter(looking_at_obj)[0]
+ else:
+ looking_at_obj=caller.location
+ ifnotlooking_at_obj:
+ caller.msg("You have no location to look at!")
+ return
+
+ ifnothasattr(looking_at_obj,"return_appearance"):
+ # this is likely due to us having an account instead
+ looking_at_obj=looking_at_obj.character
+ ifnotlooking_at_obj.access(caller,"view"):
+ caller.msg("Could not find '%s'."%args)
+ return
+ # get object's appearance
+ caller.msg(looking_at_obj.return_appearance(caller))
+ # the object's at_desc() method.
+ looking_at_obj.at_desc(looker=caller)
+
+
+# Custom build commands for setting seasonal descriptions
+# and detailing extended rooms.
+
+
+
[docs]classCmdExtendedRoomDesc(default_cmds.CmdDesc):
+ """
+ `desc` - describe an object or room.
+
+ Usage:
+ desc[/switch] [<obj> =] <description>
+
+ Switches for `desc`:
+ spring - set description for <season> in current room.
+ summer
+ autumn
+ winter
+
+ Sets the "desc" attribute on an object. If an object is not given,
+ describe the current room.
+
+ You can also embed special time markers in your room description, like this:
+
+ ```
+ <night>In the darkness, the forest looks foreboding.</night>.
+ ```
+
+ Text marked this way will only display when the server is truly at the given
+ timeslot. The available times are night, morning, afternoon and evening.
+
+ Note that seasons and time-of-day slots only work on rooms in this
+ version of the `desc` command.
+
+ """
+
+ aliases=["describe"]
+ switch_options=()# Inherits from default_cmds.CmdDesc, but unused here
+
+
[docs]defreset_times(self,obj):
+ """By deleteting the caches we force a re-load."""
+ obj.ndb.last_season=None
+ obj.ndb.last_timeslot=None
+
+
[docs]deffunc(self):
+ """Define extended command"""
+ caller=self.caller
+ location=caller.location
+ ifnotself.args:
+ iflocation:
+ string="|wDescriptions on %s|n:\n"%location.key
+ string+=" |wspring:|n %s\n"%location.db.spring_desc
+ string+=" |wsummer:|n %s\n"%location.db.summer_desc
+ string+=" |wautumn:|n %s\n"%location.db.autumn_desc
+ string+=" |wwinter:|n %s\n"%location.db.winter_desc
+ string+=" |wgeneral:|n %s"%location.db.general_desc
+ caller.msg(string)
+ return
+ ifself.switchesandself.switches[0]in("spring","summer","autumn","winter"):
+ # a seasonal switch was given
+ ifself.rhs:
+ caller.msg("Seasonal descs only work with rooms, not objects.")
+ return
+ switch=self.switches[0]
+ ifnotlocation:
+ caller.msg("No location was found!")
+ return
+ ifswitch=="spring":
+ location.db.spring_desc=self.args
+ elifswitch=="summer":
+ location.db.summer_desc=self.args
+ elifswitch=="autumn":
+ location.db.autumn_desc=self.args
+ elifswitch=="winter":
+ location.db.winter_desc=self.args
+ # clear flag to force an update
+ self.reset_times(location)
+ caller.msg("Seasonal description was set on %s."%location.key)
+ else:
+ # No seasonal desc set, maybe this is not an extended room
+ ifself.rhs:
+ text=self.rhs
+ obj=caller.search(self.lhs)
+ ifnotobj:
+ return
+ else:
+ text=self.args
+ obj=location
+ obj.db.desc=text# a compatibility fallback
+ ifobj.attributes.has("general_desc"):
+ obj.db.general_desc=text
+ self.reset_times(obj)
+ caller.msg("General description was set on %s."%obj.key)
+ else:
+ # this is not an ExtendedRoom.
+ caller.msg("The description was set on %s."%obj.key)
+
+
+
[docs]classCmdExtendedRoomDetail(default_cmds.MuxCommand):
+
+ """
+ sets a detail on a room
+
+ Usage:
+ @detail[/del] <key> [= <description>]
+ @detail <key>;<alias>;... = description
+
+ Example:
+ @detail
+ @detail walls = The walls are covered in ...
+ @detail castle;ruin;tower = The distant ruin ...
+ @detail/del wall
+ @detail/del castle;ruin;tower
+
+ This command allows to show the current room details if you enter it
+ without any argument. Otherwise, sets or deletes a detail on the current
+ room, if this room supports details like an extended room. To add new
+ detail, just use the @detail command, specifying the key, an equal sign
+ and the description. You can assign the same description to several
+ details using the alias syntax (replace key by alias1;alias2;alias3;...).
+ To remove one or several details, use the @detail/del switch.
+
+ """
+
+ key="@detail"
+ locks="cmd:perm(Builder)"
+ help_category="Building"
+
+
[docs]deffunc(self):
+ location=self.caller.location
+ ifnotself.args:
+ details=location.db.details
+ ifnotdetails:
+ self.msg("|rThe room {} doesn't have any detail set.|n".format(location))
+ else:
+ details=sorted(["|y{}|n: {}".format(key,desc)forkey,descindetails.items()])
+ self.msg("Details on Room:\n"+"\n".join(details))
+ return
+
+ ifnotself.rhsand"del"notinself.switches:
+ detail=location.return_detail(self.lhs)
+ ifdetail:
+ self.msg("Detail '|y{}|n' on Room:\n{}".format(self.lhs,detail))
+ else:
+ self.msg("Detail '{}' not found.".format(self.lhs))
+ return
+
+ method="set_detail"if"del"notinself.switcheselse"del_detail"
+ ifnothasattr(location,method):
+ self.caller.msg("Details cannot be set on %s."%location)
+ return
+ forkeyinself.lhs.split(";"):
+ # loop over all aliases, if any (if not, this will just be
+ # the one key to loop over)
+ getattr(location,method)(key,self.rhs)
+ if"del"inself.switches:
+ self.caller.msg("Detail %s deleted, if it existed."%self.lhs)
+ else:
+ self.caller.msg("Detail set '%s': '%s'"%(self.lhs,self.rhs))
+
+
+# Simple command to view the current time and season
+
+
+
[docs]classCmdExtendedRoomGameTime(default_cmds.MuxCommand):
+ """
+ Check the game time
+
+ Usage:
+ time
+
+ Shows the current in-game time and season.
+ """
+
+ key="time"
+ locks="cmd:all()"
+ help_category="General"
+
+
[docs]deffunc(self):
+ """Reads time info from current room"""
+ location=self.caller.location
+ ifnotlocationornothasattr(location,"get_time_and_season"):
+ self.caller.msg("No location available - you are outside time.")
+ else:
+ season,timeslot=location.get_time_and_season()
+ prep="a"
+ ifseason=="autumn":
+ prep="an"
+ self.caller.msg("It's %s%s day, in the %s."%(prep,season,timeslot))
+
+
+# CmdSet for easily install all commands
+
+
+
[docs]classExtendedRoomCmdSet(CmdSet):
+ """
+ Groups the extended-room commands.
+
+ """
+
+
+"""
+Easy fillable form
+
+Contrib - Tim Ashley Jenkins 2018
+
+This module contains a function that calls an easily customizable EvMenu - this
+menu presents the player with a fillable form, with fields that can be filled
+out in any order. Each field's value can be verified, with the function
+allowing easy checks for text and integer input, minimum and maximum values /
+character lengths, or can even be verified by a custom function. Once the form
+is submitted, the form's data is submitted as a dictionary to any callable of
+your choice.
+
+The function that initializes the fillable form menu is fairly simple, and
+includes the caller, the template for the form, and the callback(caller, result) to which the form
+data will be sent to upon submission.
+
+ init_fill_field(formtemplate, caller, formcallback)
+
+Form templates are defined as a list of dictionaries - each dictionary
+represents a field in the form, and contains the data for the field's name and
+behavior. For example, this basic form template will allow a player to fill out
+a brief character profile:
+
+ PROFILE_TEMPLATE = [
+ {"fieldname":"Name", "fieldtype":"text"},
+ {"fieldname":"Age", "fieldtype":"number"},
+ {"fieldname":"History", "fieldtype":"text"},
+ ]
+
+This will present the player with an EvMenu showing this basic form:
+
+ Name:
+ Age:
+ History:
+
+While in this menu, the player can assign a new value to any field with the
+syntax <field> = <new value>, like so:
+
+ > name = Ashley
+ Field 'Name' set to: Ashley
+
+Typing 'look' by itself will show the form and its current values.
+
+ > look
+
+ Name: Ashley
+ Age:
+ History:
+
+Number fields require an integer input, and will reject any text that can't
+be converted into an integer.
+
+ > age = youthful
+ Field 'Age' requires a number.
+ > age = 31
+ Field 'Age' set to: 31
+
+Form data is presented as an EvTable, so text of any length will wrap cleanly.
+
+ > history = EVERY MORNING I WAKE UP AND OPEN PALM SLAM[...]
+ Field 'History' set to: EVERY MORNING I WAKE UP AND[...]
+ > look
+
+ Name: Ashley
+ Age: 31
+ History: EVERY MORNING I WAKE UP AND OPEN PALM SLAM A VHS INTO THE SLOT.
+ IT'S CHRONICLES OF RIDDICK AND RIGHT THEN AND THERE I START DOING
+ THE MOVES ALONGSIDE WITH THE MAIN CHARACTER, RIDDICK. I DO EVERY
+ MOVE AND I DO EVERY MOVE HARD.
+
+When the player types 'submit' (or your specified submit command), the menu
+quits and the form's data is passed to your specified function as a dictionary,
+like so:
+
+ formdata = {"Name":"Ashley", "Age":31, "History":"EVERY MORNING I[...]"}
+
+You can do whatever you like with this data in your function - forms can be used
+to set data on a character, to help builders create objects, or for players to
+craft items or perform other complicated actions with many variables involved.
+
+The data that your form will accept can also be specified in your form template -
+let's say, for example, that you won't accept ages under 18 or over 100. You can
+do this by specifying "min" and "max" values in your field's dictionary:
+
+ PROFILE_TEMPLATE = [
+ {"fieldname":"Name", "fieldtype":"text"},
+ {"fieldname":"Age", "fieldtype":"number", "min":18, "max":100},
+ {"fieldname":"History", "fieldtype":"text"}
+ ]
+
+Now if the player tries to enter a value out of range, the form will not acept the
+given value.
+
+ > age = 10
+ Field 'Age' reqiures a minimum value of 18.
+ > age = 900
+ Field 'Age' has a maximum value of 100.
+
+Setting 'min' and 'max' for a text field will instead act as a minimum or
+maximum character length for the player's input.
+
+There are lots of ways to present the form to the player - fields can have default
+values or show a custom message in place of a blank value, and player input can be
+verified by a custom function, allowing for a great deal of flexibility. There
+is also an option for 'bool' fields, which accept only a True / False input and
+can be customized to represent the choice to the player however you like (E.G.
+Yes/No, On/Off, Enabled/Disabled, etc.)
+
+This module contains a simple example form that demonstrates all of the included
+functionality - a command that allows a player to compose a message to another
+online character and have it send after a custom delay. You can test it by
+importing this module in your game's default_cmdsets.py module and adding
+CmdTestMenu to your default character's command set.
+
+FIELD TEMPLATE KEYS:
+Required:
+ fieldname (str): Name of the field, as presented to the player.
+ fieldtype (str): Type of value required: 'text', 'number', or 'bool'.
+
+Optional:
+ max (int): Maximum character length (if text) or value (if number).
+ min (int): Minimum charater length (if text) or value (if number).
+ truestr (str): String for a 'True' value in a bool field.
+ (E.G. 'On', 'Enabled', 'Yes')
+ falsestr (str): String for a 'False' value in a bool field.
+ (E.G. 'Off', 'Disabled', 'No')
+ default (str): Initial value (blank if not given).
+ blankmsg (str): Message to show in place of value when field is blank.
+ cantclear (bool): Field can't be cleared if True.
+ required (bool): If True, form cannot be submitted while field is blank.
+ verifyfunc (callable): Name of a callable used to verify input - takes
+ (caller, value) as arguments. If the function returns True,
+ the player's input is considered valid - if it returns False,
+ the input is rejected. Any other value returned will act as
+ the field's new value, replacing the player's input. This
+ allows for values that aren't strings or integers (such as
+ object dbrefs). For boolean fields, return '0' or '1' to set
+ the field to False or True.
+"""
+
+fromevennia.utilsimportevmenu,evtable,delay,list_to_string,logger
+fromevenniaimportCommand
+fromevennia.server.sessionhandlerimportSESSIONS
+
+
+
[docs]classFieldEvMenu(evmenu.EvMenu):
+ """
+ Custom EvMenu type with its own node formatter - removes extraneous lines
+ """
+
+
[docs]defnode_formatter(self,nodetext,optionstext):
+ """
+ Formats the entirety of the node.
+
+ Args:
+ nodetext (str): The node text as returned by `self.nodetext_formatter`.
+ optionstext (str): The options display as returned by `self.options_formatter`.
+ caller (Object, Account or None, optional): The caller of the node.
+
+ Returns:
+ node (str): The formatted node to display.
+
+ """
+ # Only return node text, no options or separators
+ returnnodetext
+
+
+
[docs]definit_fill_field(
+ formtemplate,
+ caller,
+ formcallback,
+ pretext="",
+ posttext="",
+ submitcmd="submit",
+ borderstyle="cells",
+ formhelptext=None,
+ persistent=False,
+ initial_formdata=None,
+):
+ """
+ Initializes a menu presenting a player with a fillable form - once the form
+ is submitted, the data will be passed as a dictionary to your chosen
+ function.
+
+ Args:
+ formtemplate (list of dicts): The template for the form's fields.
+ caller (obj): Player who will be filling out the form.
+ formcallback (callable): Function to pass the completed form's data to.
+
+ Options:
+ pretext (str): Text to put before the form in the menu.
+ posttext (str): Text to put after the form in the menu.
+ submitcmd (str): Command used to submit the form.
+ borderstyle (str): Form's EvTable border style.
+ formhelptext (str): Help text for the form menu (or default is provided).
+ persistent (bool): Whether to make the EvMenu persistent across reboots.
+ initial_formdata (dict): Initial data for the form - a blank form with
+ defaults specified in the template will be generated otherwise.
+ In the case of a form used to edit properties on an object or a
+ similar application, you may want to generate the initial form
+ data dynamically before calling init_fill_field.
+ """
+
+ # Initialize form data from the template if none provided
+ formdata=form_template_to_dict(formtemplate)
+ ifinitial_formdata:
+ formdata=initial_formdata
+
+ # Provide default help text if none given
+ ifformhelptextisNone:
+ formhelptext=(
+ "Available commands:|/"
+ "|w<field> = <new value>:|n Set given field to new value, replacing the old value|/"
+ "|wclear <field>:|n Clear the value in the given field, making it blank|/"
+ "|wlook|n: Show the form's current values|/"
+ "|whelp|n: Display this help screen|/"
+ "|wquit|n: Quit the form menu without submitting|/"
+ "|w%s|n: Submit this form and quit the menu"%submitcmd
+ )
+
+ # Pass kwargs to store data needed in the menu
+ kwargs={
+ "formdata":formdata,
+ "formtemplate":formtemplate,
+ "formcallback":formcallback,
+ "pretext":pretext,
+ "posttext":posttext,
+ "submitcmd":submitcmd,
+ "borderstyle":borderstyle,
+ "formhelptext":formhelptext,
+ }
+
+ # Initialize menu of selections
+ FieldEvMenu(
+ caller,
+ "evennia.contrib.fieldfill",
+ startnode="menunode_fieldfill",
+ auto_look=False,
+ persistent=persistent,
+ **kwargs,
+ )
+
+
+
[docs]defmenunode_fieldfill(caller,raw_string,**kwargs):
+ """
+ This is an EvMenu node, which calls itself over and over in order to
+ allow a player to enter values into a fillable form. When the form is
+ submitted, the form data is passed to a callback as a dictionary.
+ """
+
+ # Retrieve menu info - taken from ndb if not persistent or db if persistent
+ ifnotcaller.db._menutree:
+ formdata=caller.ndb._menutree.formdata
+ formtemplate=caller.ndb._menutree.formtemplate
+ formcallback=caller.ndb._menutree.formcallback
+ pretext=caller.ndb._menutree.pretext
+ posttext=caller.ndb._menutree.posttext
+ submitcmd=caller.ndb._menutree.submitcmd
+ borderstyle=caller.ndb._menutree.borderstyle
+ formhelptext=caller.ndb._menutree.formhelptext
+ else:
+ formdata=caller.db._menutree.formdata
+ formtemplate=caller.db._menutree.formtemplate
+ formcallback=caller.db._menutree.formcallback
+ pretext=caller.db._menutree.pretext
+ posttext=caller.db._menutree.posttext
+ submitcmd=caller.db._menutree.submitcmd
+ borderstyle=caller.db._menutree.borderstyle
+ formhelptext=caller.db._menutree.formhelptext
+
+ # Syntax error
+ syntax_err=(
+ "Syntax: <field> = <new value>|/Or: clear <field>, help, look, quit|/'%s' to submit form"
+ %submitcmd
+ )
+
+ # Display current form data
+ text=(
+ display_formdata(
+ formtemplate,formdata,pretext=pretext,posttext=posttext,borderstyle=borderstyle
+ ),
+ formhelptext,
+ )
+ options={"key":"_default","goto":"menunode_fieldfill"}
+
+ ifraw_string:
+ # Test for given 'submit' command
+ ifraw_string.lower().strip()==submitcmd:
+ # Test to see if any blank fields are required
+ blank_and_required=[]
+ forfieldinformtemplate:
+ if"required"infield.keys():
+ # If field is required but current form data for field is blank
+ iffield["required"]isTrueandformdata[field["fieldname"]]isNone:
+ # Add to blank and required fields
+ blank_and_required.append(field["fieldname"])
+ iflen(blank_and_required)>0:
+ # List the required fields left empty to the player
+ caller.msg(
+ "The following blank fields require a value: %s"
+ %list_to_string(blank_and_required)
+ )
+ text=(None,formhelptext)
+ returntext,options
+
+ # If everything checks out, pass form data to the callback and end the menu!
+ try:
+ formcallback(caller,formdata)
+ exceptException:
+ logger.log_trace("Error in fillable form callback.")
+ returnNone,None
+
+ # Test for 'look' command
+ ifraw_string.lower().strip()=="look"orraw_string.lower().strip()=="l":
+ returntext,options
+
+ # Test for 'clear' command
+ cleartest=raw_string.lower().strip().split(" ",1)
+ ifcleartest[0].lower()=="clear":
+ text=(None,formhelptext)
+ iflen(cleartest)<2:
+ caller.msg(syntax_err)
+ returntext,options
+ matched_field=None
+
+ forkeyinformdata.keys():
+ ifcleartest[1].lower()inkey.lower():
+ matched_field=key
+
+ ifnotmatched_field:
+ caller.msg("Field '%s' does not exist!"%cleartest[1])
+ text=(None,formhelptext)
+ returntext,options
+
+ # Test to see if field can be cleared
+ forfieldinformtemplate:
+ iffield["fieldname"]==matched_fieldand"cantclear"infield.keys():
+ iffield["cantclear"]isTrue:
+ caller.msg("Field '%s' can't be cleared!"%matched_field)
+ text=(None,formhelptext)
+ returntext,options
+
+ # Clear the field
+ formdata.update({matched_field:None})
+ caller.ndb._menutree.formdata=formdata
+ caller.msg("Field '%s' cleared."%matched_field)
+ returntext,options
+
+ if"="notinraw_string:
+ text=(None,formhelptext)
+ caller.msg(syntax_err)
+ returntext,options
+
+ # Extract field name and new field value
+ entry=raw_string.split("=",1)
+ fieldname=entry[0].strip()
+ newvalue=entry[1].strip()
+
+ # Syntax error if field name is too short or blank
+ iflen(fieldname)<1:
+ caller.msg(syntax_err)
+ text=(None,formhelptext)
+ returntext,options
+
+ # Attempt to match field name to field in form data
+ matched_field=None
+ forkeyinformdata.keys():
+ iffieldname.lower()inkey.lower():
+ matched_field=key
+
+ # No matched field
+ ifmatched_fieldisNone:
+ caller.msg("Field '%s' does not exist!"%fieldname)
+ text=(None,formhelptext)
+ returntext,options
+
+ # Set new field value if match
+ # Get data from template
+ fieldtype=None
+ max_value=None
+ min_value=None
+ truestr="True"
+ falsestr="False"
+ verifyfunc=None
+ forfieldinformtemplate:
+ iffield["fieldname"]==matched_field:
+ fieldtype=field["fieldtype"]
+ if"max"infield.keys():
+ max_value=field["max"]
+ if"min"infield.keys():
+ min_value=field["min"]
+ if"truestr"infield.keys():
+ truestr=field["truestr"]
+ if"falsestr"infield.keys():
+ falsestr=field["falsestr"]
+ if"verifyfunc"infield.keys():
+ verifyfunc=field["verifyfunc"]
+
+ # Field type text verification
+ iffieldtype=="text":
+ # Test for max/min
+ ifmax_valueisnotNone:
+ iflen(newvalue)>max_value:
+ caller.msg(
+ "Field '%s' has a maximum length of %i characters."
+ %(matched_field,max_value)
+ )
+ text=(None,formhelptext)
+ returntext,options
+ ifmin_valueisnotNone:
+ iflen(newvalue)<min_value:
+ caller.msg(
+ "Field '%s' reqiures a minimum length of %i characters."
+ %(matched_field,min_value)
+ )
+ text=(None,formhelptext)
+ returntext,options
+
+ # Field type number verification
+ iffieldtype=="number":
+ try:
+ newvalue=int(newvalue)
+ except:
+ caller.msg("Field '%s' requires a number."%matched_field)
+ text=(None,formhelptext)
+ returntext,options
+ # Test for max/min
+ ifmax_valueisnotNone:
+ ifnewvalue>max_value:
+ caller.msg("Field '%s' has a maximum value of %i."%(matched_field,max_value))
+ text=(None,formhelptext)
+ returntext,options
+ ifmin_valueisnotNone:
+ ifnewvalue<min_value:
+ caller.msg(
+ "Field '%s' reqiures a minimum value of %i."%(matched_field,min_value)
+ )
+ text=(None,formhelptext)
+ returntext,options
+
+ # Field type bool verification
+ iffieldtype=="bool":
+ ifnewvalue.lower()!=truestr.lower()andnewvalue.lower()!=falsestr.lower():
+ caller.msg(
+ "Please enter '%s' or '%s' for field '%s'."%(truestr,falsestr,matched_field)
+ )
+ text=(None,formhelptext)
+ returntext,options
+ ifnewvalue.lower()==truestr.lower():
+ newvalue=True
+ elifnewvalue.lower()==falsestr.lower():
+ newvalue=False
+
+ # Call verify function if present
+ ifverifyfunc:
+ ifverifyfunc(caller,newvalue)isFalse:
+ # No error message is given - should be provided by verifyfunc
+ text=(None,formhelptext)
+ returntext,options
+ elifverifyfunc(caller,newvalue)isnotTrue:
+ newvalue=verifyfunc(caller,newvalue)
+ # Set '0' or '1' to True or False if the field type is bool
+ iffieldtype=="bool":
+ ifnewvalue==0:
+ newvalue=False
+ elifnewvalue==1:
+ newvalue=True
+
+ # If everything checks out, update form!!
+ formdata.update({matched_field:newvalue})
+ caller.ndb._menutree.formdata=formdata
+
+ # Account for truestr and falsestr when updating a boolean form
+ announced_newvalue=newvalue
+ ifnewvalueisTrue:
+ announced_newvalue=truestr
+ elifnewvalueisFalse:
+ announced_newvalue=falsestr
+
+ # Announce the new value to the player
+ caller.msg("Field '%s' set to: %s"%(matched_field,str(announced_newvalue)))
+ text=(None,formhelptext)
+
+ returntext,options
+
+
+
[docs]defform_template_to_dict(formtemplate):
+ """
+ Initializes a dictionary of form data from the given list-of-dictionaries
+ form template, as formatted above.
+
+ Args:
+ formtemplate (list of dicts): Tempate for the form to be initialized.
+
+ Returns:
+ formdata (dict): Dictionary of initalized form data.
+ """
+ formdata={}
+
+ forfieldinformtemplate:
+ # Value is blank by default
+ fieldvalue=None
+ if"default"infield:
+ # Add in default value if present
+ fieldvalue=field["default"]
+ formdata.update({field["fieldname"]:fieldvalue})
+
+ returnformdata
+
+
+
[docs]defdisplay_formdata(formtemplate,formdata,pretext="",posttext="",borderstyle="cells"):
+ """
+ Displays a form's current data as a table. Used in the form menu.
+
+ Args:
+ formtemplate (list of dicts): Template for the form
+ formdata (dict): Form's current data
+
+ Options:
+ pretext (str): Text to put before the form table.
+ posttext (str): Text to put after the form table.
+ borderstyle (str): EvTable's border style.
+ """
+
+ formtable=evtable.EvTable(border=borderstyle,valign="t",maxwidth=80)
+ field_name_width=5
+
+ forfieldinformtemplate:
+ new_fieldname=None
+ new_fieldvalue=None
+ # Get field name
+ new_fieldname="|w"+field["fieldname"]+":|n"
+ iflen(field["fieldname"])+5>field_name_width:
+ field_name_width=len(field["fieldname"])+5
+ # Get field value
+ ifformdata[field["fieldname"]]isnotNone:
+ new_fieldvalue=str(formdata[field["fieldname"]])
+ # Use blank message if field is blank and once is present
+ ifnew_fieldvalueisNoneand"blankmsg"infield:
+ new_fieldvalue="|x"+str(field["blankmsg"])+"|n"
+ elifnew_fieldvalueisNone:
+ new_fieldvalue=" "
+ # Replace True and False values with truestr and falsestr from template
+ ifformdata[field["fieldname"]]isTrueand"truestr"infield:
+ new_fieldvalue=field["truestr"]
+ elifformdata[field["fieldname"]]isFalseand"falsestr"infield:
+ new_fieldvalue=field["falsestr"]
+ # Add name and value to table
+ formtable.add_row(new_fieldname,new_fieldvalue)
+
+ formtable.reformat_column(0,align="r",width=field_name_width)
+
+ returnpretext+"|/"+str(formtable)+"|/"+posttext
+
+
+# EXAMPLE FUNCTIONS / COMMAND STARTS HERE
+
+
+
[docs]defverify_online_player(caller,value):
+ """
+ Example 'verify function' that matches player input to an online character
+ or else rejects their input as invalid.
+
+ Args:
+ caller (obj): Player entering the form data.
+ value (str): String player entered into the form, to be verified.
+
+ Returns:
+ matched_character (obj or False): dbref to a currently logged in
+ character object - reference to the object will be stored in
+ the form instead of a string. Returns False if no match is
+ made.
+ """
+ # Get a list of sessions
+ session_list=SESSIONS.get_sessions()
+ char_list=[]
+ matched_character=None
+
+ # Get a list of online characters
+ forsessioninsession_list:
+ ifnotsession.logged_in:
+ # Skip over logged out characters
+ continue
+ # Append to our list of online characters otherwise
+ char_list.append(session.get_puppet())
+
+ # Match player input to a character name
+ forcharacterinchar_list:
+ ifvalue.lower()==character.key.lower():
+ matched_character=character
+
+ # If input didn't match to a character
+ ifnotmatched_character:
+ # Send the player an error message unique to this function
+ caller.msg("No character matching '%s' is online."%value)
+ # Returning False indicates the new value is not valid
+ returnFalse
+
+ # Returning anything besides True or False will replace the player's input with the returned
+ # value. In this case, the value becomes a reference to the character object. You can store data
+ # besides strings and integers in the 'formdata' dictionary this way!
+ returnmatched_character
+
+
+# Form template for the example 'delayed message' form
+SAMPLE_FORM=[
+ {
+ "fieldname":"Character",
+ "fieldtype":"text",
+ "max":30,
+ "blankmsg":"(Name of an online player)",
+ "required":True,
+ "verifyfunc":verify_online_player,
+ },
+ {
+ "fieldname":"Delay",
+ "fieldtype":"number",
+ "min":3,
+ "max":30,
+ "default":10,
+ "cantclear":True,
+ },
+ {
+ "fieldname":"Message",
+ "fieldtype":"text",
+ "min":3,
+ "max":200,
+ "blankmsg":"(Message up to 200 characters)",
+ },
+ {
+ "fieldname":"Anonymous",
+ "fieldtype":"bool",
+ "truestr":"Yes",
+ "falsestr":"No",
+ "default":False,
+ },
+]
+
+
+
[docs]classCmdTestMenu(Command):
+ """
+ This test command will initialize a menu that presents you with a form.
+ You can fill out the fields of this form in any order, and then type in
+ 'send' to send a message to another online player, which will reach them
+ after a delay you specify.
+
+ Usage:
+ <field> = <new value>
+ clear <field>
+ help
+ look
+ quit
+ send
+ """
+
+ key="testmenu"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ pretext=(
+ "|cSend a delayed message to another player ---------------------------------------|n"
+ )
+ posttext=(
+ "|c--------------------------------------------------------------------------------|n|/"
+ "Syntax: type |c<field> = <new value>|n to change the values of the form. Given|/"
+ "player must be currently logged in, delay is given in seconds. When you are|/"
+ "finished, type '|csend|n' to send the message.|/"
+ )
+
+ init_fill_field(
+ SAMPLE_FORM,
+ self.caller,
+ init_delayed_message,
+ pretext=pretext,
+ posttext=posttext,
+ submitcmd="send",
+ borderstyle="none",
+ )
+
+
+
[docs]defsendmessage(obj,text):
+ """
+ Callback to send a message to a player.
+
+ Args:
+ obj (obj): Player to message.
+ text (str): Message.
+ """
+ obj.msg(text)
+
+
+
[docs]definit_delayed_message(caller,formdata):
+ """
+ Initializes a delayed message, using data from the example form.
+
+ Args:
+ caller (obj): Character submitting the message.
+ formdata (dict): Data from submitted form.
+ """
+ # Retrieve data from the filled out form.
+ # We stored the character to message as an object ref using a verifyfunc
+ # So we don't have to do any more searching or matching here!
+ player_to_message=formdata["Character"]
+ message_delay=formdata["Delay"]
+ sender=str(caller)
+ ifformdata["Anonymous"]isTrue:
+ sender="anonymous"
+ message=("Message from %s: "%sender)+str(formdata["Message"])
+
+ caller.msg("Message sent to %s!"%player_to_message)
+ # Make a deferred call to 'sendmessage' above.
+ delay(message_delay,sendmessage,player_to_message,message)
+ return
+"""
+Gendersub
+
+Griatch 2015
+
+This is a simple gender-aware Character class for allowing users to
+insert custom markers in their text to indicate gender-aware
+messaging. It relies on a modified msg() and is meant as an
+inspiration and starting point to how to do stuff like this.
+
+An object can have the following genders:
+ - male (he/his)
+ - female (her/hers)
+ - neutral (it/its)
+ - ambiguous (they/them/their/theirs)
+
+When in use, messages can contain special tags to indicate pronouns gendered
+based on the one being addressed. Capitalization will be retained.
+
+- `|s`, `|S`: Subjective form: he, she, it, He, She, It, They
+- `|o`, `|O`: Objective form: him, her, it, Him, Her, It, Them
+- `|p`, `|P`: Possessive form: his, her, its, His, Her, Its, Their
+- `|a`, `|A`: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs
+
+For example,
+
+```
+char.msg("%s falls on |p face with a thud." % char.key)
+"Tom falls on his face with a thud"
+```
+
+The default gender is "ambiguous" (they/them/their/theirs).
+
+To use, have DefaultCharacter inherit from this, or change
+setting.DEFAULT_CHARACTER to point to this class.
+
+The `@gender` command is used to set the gender. It needs to be added to the
+default cmdset before it becomes available.
+
+"""
+
+importre
+fromevennia.utilsimportlogger
+fromevenniaimportDefaultCharacter
+fromevenniaimportCommand
+
+# gender maps
+
+_GENDER_PRONOUN_MAP={
+ "male":{"s":"he","o":"him","p":"his","a":"his"},
+ "female":{"s":"she","o":"her","p":"her","a":"hers"},
+ "neutral":{"s":"it","o":"it","p":"its","a":"its"},
+ "ambiguous":{"s":"they","o":"them","p":"their","a":"theirs"},
+}
+_RE_GENDER_PRONOUN=re.compile(r"(?<!\|)\|(?!\|)[sSoOpPaA]")
+
+# in-game command for setting the gender
+
+
+
[docs]deffunc(self):
+ """
+ Implements the command.
+ """
+ caller=self.caller
+ arg=self.args.strip().lower()
+ ifargnotin("male","female","neutral","ambiguous"):
+ caller.msg("Usage: @gender male||female||neutral||ambiguous")
+ return
+ caller.db.gender=arg
+ caller.msg("Your gender was set to %s."%arg)
+
+
+# Gender-aware character class
+
+
+
[docs]classGenderCharacter(DefaultCharacter):
+ """
+ This is a Character class aware of gender.
+
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called once when the object is created.
+ """
+ super().at_object_creation()
+ self.db.gender="ambiguous"
+
+ def_get_pronoun(self,regex_match):
+ """
+ Get pronoun from the pronoun marker in the text. This is used as
+ the callable for the re.sub function.
+
+ Args:
+ regex_match (MatchObject): the regular expression match.
+
+ Notes:
+ - `|s`, `|S`: Subjective form: he, she, it, He, She, It, They
+ - `|o`, `|O`: Objective form: him, her, it, Him, Her, It, Them
+ - `|p`, `|P`: Possessive form: his, her, its, His, Her, Its, Their
+ - `|a`, `|A`: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs
+
+ """
+ typ=regex_match.group()[1]# "s", "O" etc
+ gender=self.attributes.get("gender",default="ambiguous")
+ gender=genderifgenderin("male","female","neutral")else"ambiguous"
+ pronoun=_GENDER_PRONOUN_MAP[gender][typ.lower()]
+ returnpronoun.capitalize()iftyp.isupper()elsepronoun
+
+
[docs]defmsg(self,text=None,from_obj=None,session=None,**kwargs):
+ """
+ Emits something to a session attached to the object.
+ Overloads the default msg() implementation to include
+ gender-aware markers in output.
+
+ Args:
+ text (str or tuple, optional): The message to send. This
+ is treated internally like any send-command, so its
+ value can be a tuple if sending multiple arguments to
+ the `text` oob command.
+ from_obj (obj, optional): object that is sending. If
+ given, at_msg_send will be called
+ session (Session or list, optional): session or list of
+ sessions to relay to, if any. If set, will
+ force send regardless of MULTISESSION_MODE.
+ Notes:
+ `at_msg_receive` will be called on this Object.
+ All extra kwargs will be passed on to the protocol.
+
+ """
+ iftextisNone:
+ super().msg(from_obj=from_obj,session=session,**kwargs)
+ return
+
+ try:
+ iftextandisinstance(text,tuple):
+ text=(_RE_GENDER_PRONOUN.sub(self._get_pronoun,text[0]),*text[1:])
+ else:
+ text=_RE_GENDER_PRONOUN.sub(self._get_pronoun,text)
+ exceptTypeError:
+ pass
+ exceptExceptionase:
+ logger.log_trace(e)
+ super().msg(text,from_obj=from_obj,session=session,**kwargs)
+"""
+Health Bar
+
+Contrib - Tim Ashley Jenkins 2017
+
+The function provided in this module lets you easily display visual
+bars or meters - "health bar" is merely the most obvious use for this,
+though these bars are highly customizable and can be used for any sort
+of appropriate data besides player health.
+
+Today's players may be more used to seeing statistics like health,
+stamina, magic, and etc. displayed as bars rather than bare numerical
+values, so using this module to present this data this way may make it
+more accessible. Keep in mind, however, that players may also be using
+a screen reader to connect to your game, which will not be able to
+represent the colors of the bar in any way. By default, the values
+represented are rendered as text inside the bar which can be read by
+screen readers.
+
+The health bar will account for current values above the maximum or
+below 0, rendering them as a completely full or empty bar with the
+values displayed within.
+"""
+
+
+
[docs]defdisplay_meter(
+ cur_value,
+ max_value,
+ length=30,
+ fill_color=["R","Y","G"],
+ empty_color="B",
+ text_color="w",
+ align="left",
+ pre_text="",
+ post_text="",
+ show_values=True,
+):
+ """
+ Represents a current and maximum value given as a "bar" rendered with
+ ANSI or xterm256 background colors.
+
+ Args:
+ cur_value (int): Current value to display
+ max_value (int): Maximum value to display
+
+ Options:
+ length (int): Length of meter returned, in characters
+ fill_color (list): List of color codes for the full portion
+ of the bar, sans any sort of prefix - both ANSI and xterm256
+ colors are usable. When the bar is empty, colors toward the
+ start of the list will be chosen - when the bar is full, colors
+ towards the end are picked. You can adjust the 'weights' of
+ the changing colors by adding multiple entries of the same
+ color - for example, if you only want the bar to change when
+ it's close to empty, you could supply ['R','Y','G','G','G']
+ empty_color (str): Color code for the empty portion of the bar.
+ text_color (str): Color code for text inside the bar.
+ align (str): "left", "right", or "center" - alignment of text in the bar
+ pre_text (str): Text to put before the numbers in the bar
+ post_text (str): Text to put after the numbers in the bar
+ show_values (bool): If true, shows the numerical values represented by
+ the bar. It's highly recommended you keep this on, especially if
+ there's no info given in pre_text or post_text, as players on screen
+ readers will be unable to read the graphical aspect of the bar.
+ """
+ # Start by building the base string.
+ num_text=""
+ ifshow_values:
+ num_text="%i / %i"%(cur_value,max_value)
+ bar_base_str=pre_text+num_text+post_text
+ # Cut down the length of the base string if needed
+ iflen(bar_base_str)>length:
+ bar_base_str=bar_base_str[:length]
+ # Pad and align the bar base string
+ ifalign=="right":
+ bar_base_str=bar_base_str.rjust(length," ")
+ elifalign=="center":
+ bar_base_str=bar_base_str.center(length," ")
+ else:
+ bar_base_str=bar_base_str.ljust(length," ")
+
+ ifmax_value<1:# Prevent divide by zero
+ max_value=1
+ ifcur_value<0:# Prevent weirdly formatted 'negative bars'
+ cur_value=0
+ ifcur_value>max_value:# Display overfull bars correctly
+ cur_value=max_value
+
+ # Now it's time to determine where to put the color codes.
+ percent_full=float(cur_value)/float(max_value)
+ split_index=round(float(length)*percent_full)
+ # Determine point at which to split the bar
+ split_index=int(split_index)
+
+ # Separate the bar string into full and empty portions
+ full_portion=bar_base_str[:split_index]
+ empty_portion=bar_base_str[split_index:]
+
+ # Pick which fill color to use based on how full the bar is
+ fillcolor_index=float(len(fill_color))*percent_full
+ fillcolor_index=max(0,int(round(fillcolor_index))-1)
+ fillcolor_code="|["+fill_color[fillcolor_index]
+
+ # Make color codes for empty bar portion and text_color
+ emptycolor_code="|["+empty_color
+ textcolor_code="|"+text_color
+
+ # Assemble the final bar
+ final_bar=(
+ fillcolor_code
+ +textcolor_code
+ +full_portion
+ +"|n"
+ +emptycolor_code
+ +textcolor_code
+ +empty_portion
+ +"|n"
+ )
+
+ returnfinal_bar
Source code for evennia.contrib.ingame_python.callbackhandler
+"""
+Module containing the CallbackHandler for individual objects.
+"""
+
+fromcollectionsimportnamedtuple
+
+
+
[docs]classCallbackHandler(object):
+
+ """
+ The callback handler for a specific object.
+
+ The script that contains all callbacks will be reached through this
+ handler. This handler is therefore a shortcut to be used by
+ developers. This handler (accessible through `obj.callbacks`) is a
+ shortcut to manipulating callbacks within this object, getting,
+ adding, editing, deleting and calling them.
+
+ """
+
+ script=None
+
+
[docs]defall(self):
+ """
+ Return all callbacks linked to this object.
+
+ Returns:
+ All callbacks in a dictionary callback_name: callback}. The callback
+ is returned as a namedtuple to simplify manipulation.
+
+ """
+ callbacks={}
+ handler=type(self).script
+ ifhandler:
+ dicts=handler.get_callbacks(self.obj)
+ forcallback_name,in_listindicts.items():
+ new_list=[]
+ forcallbackinin_list:
+ callback=self.format_callback(callback)
+ new_list.append(callback)
+
+ ifnew_list:
+ callbacks[callback_name]=new_list
+
+ returncallbacks
+
+
[docs]defget(self,callback_name):
+ """
+ Return the callbacks associated with this name.
+
+ Args:
+ callback_name (str): the name of the callback.
+
+ Returns:
+ A list of callbacks associated with this object and of this name.
+
+ Note:
+ This method returns a list of callback objects (namedtuple
+ representations). If the callback name cannot be found in the
+ object's callbacks, return an empty list.
+
+ """
+ returnself.all().get(callback_name,[])
+
+
[docs]defget_variable(self,variable_name):
+ """
+ Return the variable value or None.
+
+ Args:
+ variable_name (str): the name of the variable.
+
+ Returns:
+ Either the variable's value or None.
+
+ """
+ handler=type(self).script
+ ifhandler:
+ returnhandler.get_variable(variable_name)
+
+ returnNone
+
+
[docs]defadd(self,callback_name,code,author=None,valid=False,parameters=""):
+ """
+ Add a new callback for this object.
+
+ Args:
+ callback_name (str): the name of the callback to add.
+ code (str): the Python code associated with this callback.
+ author (Character or Account, optional): the author of the callback.
+ valid (bool, optional): should the callback be connected?
+ parameters (str, optional): optional parameters.
+
+ Returns:
+ The callback definition that was added or None.
+
+ """
+ handler=type(self).script
+ ifhandler:
+ returnself.format_callback(
+ handler.add_callback(
+ self.obj,callback_name,code,author=author,valid=valid,parameters=parameters
+ )
+ )
+
+
[docs]defedit(self,callback_name,number,code,author=None,valid=False):
+ """
+ Edit an existing callback bound to this object.
+
+ Args:
+ callback_name (str): the name of the callback to edit.
+ number (int): the callback number to be changed.
+ code (str): the Python code associated with this callback.
+ author (Character or Account, optional): the author of the callback.
+ valid (bool, optional): should the callback be connected?
+
+ Returns:
+ The callback definition that was edited or None.
+
+ Raises:
+ RuntimeError if the callback is locked.
+
+ """
+ handler=type(self).script
+ ifhandler:
+ returnself.format_callback(
+ handler.edit_callback(
+ self.obj,callback_name,number,code,author=author,valid=valid
+ )
+ )
+
+
[docs]defremove(self,callback_name,number):
+ """
+ Delete the specified callback bound to this object.
+
+ Args:
+ callback_name (str): the name of the callback to delete.
+ number (int): the number of the callback to delete.
+
+ Raises:
+ RuntimeError if the callback is locked.
+
+ """
+ handler=type(self).script
+ ifhandler:
+ handler.del_callback(self.obj,callback_name,number)
+
+
[docs]defcall(self,callback_name,*args,**kwargs):
+ """
+ Call the specified callback(s) bound to this object.
+
+ Args:
+ callback_name (str): the callback name to call.
+ *args: additional variables for this callback.
+
+ Keyword Args:
+ number (int, optional): call just a specific callback.
+ parameters (str, optional): call a callback with parameters.
+ locals (dict, optional): a locals replacement.
+
+ Returns:
+ True to report the callback was called without interruption,
+ False otherwise. If the callbackHandler isn't found, return
+ None.
+
+ """
+ handler=type(self).script
+ ifhandler:
+ returnhandler.call(self.obj,callback_name,*args,**kwargs)
+
+ returnNone
+
+
[docs]@staticmethod
+ defformat_callback(callback):
+ """
+ Return the callback namedtuple to represent the specified callback.
+
+ Args:
+ callback (dict): the callback definition.
+
+ The callback given in argument should be a dictionary containing
+ the expected fields for a callback (code, author, valid...).
+
+ """
+ if"obj"notincallback:
+ callback["obj"]=None
+ if"name"notincallback:
+ callback["name"]="unknown"
+ if"number"notincallback:
+ callback["number"]=-1
+ if"code"notincallback:
+ callback["code"]=""
+ if"author"notincallback:
+ callback["author"]=None
+ if"valid"notincallback:
+ callback["valid"]=False
+ if"parameters"notincallback:
+ callback["parameters"]=""
+ if"created_on"notincallback:
+ callback["created_on"]=None
+ if"updated_by"notincallback:
+ callback["updated_by"]=None
+ if"updated_on"notincallback:
+ callback["updated_on"]=None
+
+ returnCallback(**callback)
Source code for evennia.contrib.ingame_python.commands
+"""
+Module containing the commands of the in-game Python system.
+"""
+
+fromdatetimeimportdatetime
+
+fromdjango.confimportsettings
+fromevenniaimportCommand
+fromevennia.utils.ansiimportraw
+fromevennia.utils.eveditorimportEvEditor
+fromevennia.utils.evtableimportEvTable
+fromevennia.utils.utilsimportclass_from_module,time_format
+fromevennia.contrib.ingame_python.utilsimportget_event_handler
+
+COMMAND_DEFAULT_CLASS=class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# Permissions
+WITH_VALIDATION=getattr(settings,"callbackS_WITH_VALIDATION",None)
+WITHOUT_VALIDATION=getattr(settings,"callbackS_WITHOUT_VALIDATION","developer")
+VALIDATING=getattr(settings,"callbackS_VALIDATING","developer")
+
+# Split help text
+BASIC_HELP="Add, edit or delete callbacks."
+
+BASIC_USAGES=[
+ "@call <object name> [= <callback name>]",
+ "@call/add <object name> = <callback name> [parameters]",
+ "@call/edit <object name> = <callback name> [callback number]",
+ "@call/del <object name> = <callback name> [callback number]",
+ "@call/tasks [object name [= <callback name>]]",
+]
+
+BASIC_SWITCHES=[
+ "add - add and edit a new callback",
+ "edit - edit an existing callback",
+ "del - delete an existing callback",
+ "tasks - show the list of differed tasks",
+]
+
+VALIDATOR_USAGES=["@call/accept [object name = <callback name> [callback number]]"]
+
+VALIDATOR_SWITCHES=["accept - show callbacks to be validated or accept one"]
+
+BASIC_TEXT="""
+This command is used to manipulate callbacks. A callback can be linked to
+an object, to fire at a specific moment. You can use the command without
+switches to see what callbacks are active on an object:
+ @call self
+You can also specify a callback name if you want the list of callbacks
+associated with this object of this name:
+ @call north = can_traverse
+You can also add a number after the callback name to see details on one callback:
+ @call here = say 2
+You can also add, edit or remove callbacks using the add, edit or del switches.
+Additionally, you can see the list of differed tasks created by callbacks
+(chained events to be called) using the /tasks switch.
+"""
+
+VALIDATOR_TEXT="""
+You can also use this command to validate callbacks. Depending on your game
+setting, some users might be allowed to add new callbacks, but these callbacks
+will not be fired until you accept them. To see the callbacks needing
+validation, enter the /accept switch without argument:
+ @call/accept
+A table will show you the callbacks that are not validated yet, who created
+them and when. You can then accept a specific callback:
+ @call here = enter 1
+Use the /del switch to remove callbacks that should not be connected.
+"""
+
+
+
[docs]defget_help(self,caller,cmdset):
+ """
+ Return the help message for this command and this caller.
+
+ The help text of this specific command will vary depending
+ on user permission.
+
+ Args:
+ caller (Object or Account): the caller asking for help on the command.
+ cmdset (CmdSet): the command set (if you need additional commands).
+
+ Returns:
+ docstring (str): the help text to provide the caller for this command.
+
+ """
+ lock="perm({}) or perm(callbacks_validating)".format(VALIDATING)
+ validator=caller.locks.check_lockstring(caller,lock)
+ text="\n"+BASIC_HELP+"\n\nUsages:\n "
+
+ # Usages
+ text+="\n ".join(BASIC_USAGES)
+ ifvalidator:
+ text+="\n "+"\n ".join(VALIDATOR_USAGES)
+
+ # Switches
+ text+="\n\nSwitches:\n "
+ text+="\n ".join(BASIC_SWITCHES)
+ ifvalidator:
+ text+="\n "+"\n ".join(VALIDATOR_SWITCHES)
+
+ # Text
+ text+="\n"+BASIC_TEXT
+ ifvalidator:
+ text+="\n"+VALIDATOR_TEXT
+
+ returntext
+
+
[docs]deffunc(self):
+ """Command body."""
+ caller=self.caller
+ lock="perm({}) or perm(events_validating)".format(VALIDATING)
+ validator=caller.locks.check_lockstring(caller,lock)
+ lock="perm({}) or perm(events_without_validation)".format(WITHOUT_VALIDATION)
+ autovalid=caller.locks.check_lockstring(caller,lock)
+
+ # First and foremost, get the callback handler and set other variables
+ self.handler=get_event_handler()
+ self.obj=None
+ rhs=self.rhsor""
+ self.callback_name,sep,self.parameters=rhs.partition(" ")
+ self.callback_name=self.callback_name.lower()
+ self.is_validator=validator
+ self.autovalid=autovalid
+ ifself.handlerisNone:
+ caller.msg("The event handler is not running, can't ""access the event system.")
+ return
+
+ # Before the equal sign, there is an object name or nothing
+ ifself.lhs:
+ self.obj=caller.search(self.lhs)
+ ifnotself.obj:
+ return
+
+ # Switches are mutually exclusive
+ switch=self.switchesandself.switches[0]or""
+ ifswitchin("","add","edit","del")andself.objisNone:
+ caller.msg("Specify an object's name or #ID.")
+ return
+
+ ifswitch=="":
+ self.list_callbacks()
+ elifswitch=="add":
+ self.add_callback()
+ elifswitch=="edit":
+ self.edit_callback()
+ elifswitch=="del":
+ self.del_callback()
+ elifswitch=="accept"andvalidator:
+ self.accept_callback()
+ elifswitchin["tasks","task"]:
+ self.list_tasks()
+ else:
+ caller.msg("Mutually exclusive or invalid switches were ""used, cannot proceed.")
+
+
[docs]deflist_callbacks(self):
+ """Display the list of callbacks connected to the object."""
+ obj=self.obj
+ callback_name=self.callback_name
+ parameters=self.parameters
+ callbacks=self.handler.get_callbacks(obj)
+ types=self.handler.get_events(obj)
+
+ ifcallback_name:
+ # Check that the callback name can be found in this object
+ created=callbacks.get(callback_name)
+ ifcreatedisNone:
+ self.msg("No callback {} has been set on {}.".format(callback_name,obj))
+ return
+
+ ifparameters:
+ # Check that the parameter points to an existing callback
+ try:
+ number=int(parameters)-1
+ assertnumber>=0
+ callback=callbacks[callback_name][number]
+ except(ValueError,AssertionError,IndexError):
+ self.msg(
+ "The callback {}{} cannot be found in {}.".format(
+ callback_name,parameters,obj
+ )
+ )
+ return
+
+ # Display the callback's details
+ author=callback.get("author")
+ author=author.keyifauthorelse"|gUnknown|n"
+ updated_by=callback.get("updated_by")
+ updated_by=updated_by.keyifupdated_byelse"|gUnknown|n"
+ created_on=callback.get("created_on")
+ created_on=(
+ created_on.strftime("%Y-%m-%d %H:%M:%S")ifcreated_onelse"|gUnknown|n"
+ )
+ updated_on=callback.get("updated_on")
+ updated_on=(
+ updated_on.strftime("%Y-%m-%d %H:%M:%S")ifupdated_onelse"|gUnknown|n"
+ )
+ msg="Callback {}{} of {}:".format(callback_name,parameters,obj)
+ msg+="\nCreated by {} on {}.".format(author,created_on)
+ msg+="\nUpdated by {} on {}".format(updated_by,updated_on)
+
+ ifself.is_validator:
+ ifcallback.get("valid"):
+ msg+="\nThis callback is |rconnected|n and active."
+ else:
+ msg+="\nThis callback |rhasn't been|n accepted yet."
+
+ msg+="\nCallback code:\n"
+ msg+=raw(callback["code"])
+ self.msg(msg)
+ return
+
+ # No parameter has been specified, display the table of callbacks
+ cols=["Number","Author","Updated","Param"]
+ ifself.is_validator:
+ cols.append("Valid")
+
+ table=EvTable(*cols,width=78)
+ table.reformat_column(0,align="r")
+ now=datetime.now()
+ fori,callbackinenumerate(created):
+ author=callback.get("author")
+ author=author.keyifauthorelse"|gUnknown|n"
+ updated_on=callback.get("updated_on")
+ ifupdated_onisNone:
+ updated_on=callback.get("created_on")
+
+ ifupdated_on:
+ updated_on="{} ago".format(
+ time_format((now-updated_on).total_seconds(),4).capitalize()
+ )
+ else:
+ updated_on="|gUnknown|n"
+ parameters=callback.get("parameters","")
+
+ row=[str(i+1),author,updated_on,parameters]
+ ifself.is_validator:
+ row.append("Yes"ifcallback.get("valid")else"No")
+ table.add_row(*row)
+
+ self.msg(str(table))
+ else:
+ names=list(set(list(types.keys())+list(callbacks.keys())))
+ table=EvTable("Callback name","Number","Description",valign="t",width=78)
+ table.reformat_column(0,width=20)
+ table.reformat_column(1,width=10,align="r")
+ table.reformat_column(2,width=48)
+ fornameinsorted(names):
+ number=len(callbacks.get(name,[]))
+ lines=sum(len(e["code"].splitlines())foreincallbacks.get(name,[]))
+ no="{} ({})".format(number,lines)
+ description=types.get(name,(None,"Chained event."))[1]
+ description=description.strip("\n").splitlines()[0]
+ table.add_row(name,no,description)
+
+ self.msg(str(table))
+
+
[docs]defadd_callback(self):
+ """Add a callback."""
+ obj=self.obj
+ callback_name=self.callback_name
+ types=self.handler.get_events(obj)
+
+ # Check that the callback exists
+ ifnotcallback_name.startswith("chain_")andcallback_namenotintypes:
+ self.msg(
+ "The callback name {} can't be found in {} of "
+ "typeclass {}.".format(callback_name,obj,type(obj))
+ )
+ return
+
+ definition=types.get(callback_name,(None,"Chained event."))
+ description=definition[1]
+ self.msg(raw(description.strip("\n")))
+
+ # Open the editor
+ callback=self.handler.add_callback(
+ obj,callback_name,"",self.caller,False,parameters=self.parameters
+ )
+
+ # Lock this callback right away
+ self.handler.db.locked.append((obj,callback_name,callback["number"]))
+
+ # Open the editor for this callback
+ self.caller.db._callback=callback
+ EvEditor(
+ self.caller,
+ loadfunc=_ev_load,
+ savefunc=_ev_save,
+ quitfunc=_ev_quit,
+ key="Callback {} of {}".format(callback_name,obj),
+ persistent=True,
+ codefunc=_ev_save,
+ )
+
+
[docs]defedit_callback(self):
+ """Edit a callback."""
+ obj=self.obj
+ callback_name=self.callback_name
+ parameters=self.parameters
+ callbacks=self.handler.get_callbacks(obj)
+ types=self.handler.get_events(obj)
+
+ # If no callback name is specified, display the list of callbacks
+ ifnotcallback_name:
+ self.list_callbacks()
+ return
+
+ # Check that the callback exists
+ ifcallback_namenotincallbacks:
+ self.msg("The callback name {} can't be found in {}.".format(callback_name,obj))
+ return
+
+ # If there's only one callback, just edit it
+ iflen(callbacks[callback_name])==1:
+ number=0
+ callback=callbacks[callback_name][0]
+ else:
+ ifnotparameters:
+ self.msg("Which callback do you wish to edit? Specify a number.")
+ self.list_callbacks()
+ return
+
+ # Check that the parameter points to an existing callback
+ try:
+ number=int(parameters)-1
+ assertnumber>=0
+ callback=callbacks[callback_name][number]
+ except(ValueError,AssertionError,IndexError):
+ self.msg(
+ "The callback {}{} cannot be found in {}.".format(
+ callback_name,parameters,obj
+ )
+ )
+ return
+
+ # If caller can't edit without validation, forbid editing
+ # others' works
+ ifnotself.autovalidandcallback["author"]isnotself.caller:
+ self.msg("You cannot edit this callback created by someone else.")
+ return
+
+ # If the callback is locked (edited by someone else)
+ if(obj,callback_name,number)inself.handler.db.locked:
+ self.msg("This callback is locked, you cannot edit it.")
+ return
+
+ self.handler.db.locked.append((obj,callback_name,number))
+
+ # Check the definition of the callback
+ definition=types.get(callback_name,(None,"Chained event."))
+ description=definition[1]
+ self.msg(raw(description.strip("\n")))
+
+ # Open the editor
+ callback=dict(callback)
+ self.caller.db._callback=callback
+ EvEditor(
+ self.caller,
+ loadfunc=_ev_load,
+ savefunc=_ev_save,
+ quitfunc=_ev_quit,
+ key="Callback {} of {}".format(callback_name,obj),
+ persistent=True,
+ codefunc=_ev_save,
+ )
+
+
[docs]defdel_callback(self):
+ """Delete a callback."""
+ obj=self.obj
+ callback_name=self.callback_name
+ parameters=self.parameters
+ callbacks=self.handler.get_callbacks(obj)
+ types=self.handler.get_events(obj)
+
+ # If no callback name is specified, display the list of callbacks
+ ifnotcallback_name:
+ self.list_callbacks()
+ return
+
+ # Check that the callback exists
+ ifcallback_namenotincallbacks:
+ self.msg("The callback name {} can't be found in {}.".format(callback_name,obj))
+ return
+
+ # If there's only one callback, just delete it
+ iflen(callbacks[callback_name])==1:
+ number=0
+ callback=callbacks[callback_name][0]
+ else:
+ ifnotparameters:
+ self.msg("Which callback do you wish to delete? Specify ""a number.")
+ self.list_callbacks()
+ return
+
+ # Check that the parameter points to an existing callback
+ try:
+ number=int(parameters)-1
+ assertnumber>=0
+ callback=callbacks[callback_name][number]
+ except(ValueError,AssertionError,IndexError):
+ self.msg(
+ "The callback {}{} cannot be found in {}.".format(
+ callback_name,parameters,obj
+ )
+ )
+ return
+
+ # If caller can't edit without validation, forbid deleting
+ # others' works
+ ifnotself.autovalidandcallback["author"]isnotself.caller:
+ self.msg("You cannot delete this callback created by someone else.")
+ return
+
+ # If the callback is locked (edited by someone else)
+ if(obj,callback_name,number)inself.handler.db.locked:
+ self.msg("This callback is locked, you cannot delete it.")
+ return
+
+ # Delete the callback
+ self.handler.del_callback(obj,callback_name,number)
+ self.msg("The callback {}[{}] of {} was deleted.".format(callback_name,number+1,obj))
+
+
[docs]defaccept_callback(self):
+ """Accept a callback."""
+ obj=self.obj
+ callback_name=self.callback_name
+ parameters=self.parameters
+
+ # If no object, display the list of callbacks to be checked
+ ifobjisNone:
+ table=EvTable("ID","Type","Object","Name","Updated by","On",width=78)
+ table.reformat_column(0,align="r")
+ now=datetime.now()
+ forobj,name,numberinself.handler.db.to_valid:
+ callbacks=self.handler.get_callbacks(obj).get(name)
+ ifcallbacksisNone:
+ continue
+
+ try:
+ callback=callbacks[number]
+ exceptIndexError:
+ continue
+
+ type_name=obj.typeclass_path.split(".")[-1]
+ by=callback.get("updated_by")
+ by=by.keyifbyelse"|gUnknown|n"
+ updated_on=callback.get("updated_on")
+ ifupdated_onisNone:
+ updated_on=callback.get("created_on")
+
+ ifupdated_on:
+ updated_on="{} ago".format(
+ time_format((now-updated_on).total_seconds(),4).capitalize()
+ )
+ else:
+ updated_on="|gUnknown|n"
+
+ table.add_row(obj.id,type_name,obj,name,by,updated_on)
+ self.msg(str(table))
+ return
+
+ # An object was specified
+ callbacks=self.handler.get_callbacks(obj)
+ types=self.handler.get_events(obj)
+
+ # If no callback name is specified, display the list of callbacks
+ ifnotcallback_name:
+ self.list_callbacks()
+ return
+
+ # Check that the callback exists
+ ifcallback_namenotincallbacks:
+ self.msg("The callback name {} can't be found in {}.".format(callback_name,obj))
+ return
+
+ ifnotparameters:
+ self.msg("Which callback do you wish to accept? Specify a number.")
+ self.list_callbacks()
+ return
+
+ # Check that the parameter points to an existing callback
+ try:
+ number=int(parameters)-1
+ assertnumber>=0
+ callback=callbacks[callback_name][number]
+ except(ValueError,AssertionError,IndexError):
+ self.msg(
+ "The callback {}{} cannot be found in {}.".format(callback_name,parameters,obj)
+ )
+ return
+
+ # Accept the callback
+ ifcallback["valid"]:
+ self.msg("This callback has already been accepted.")
+ else:
+ self.handler.accept_callback(obj,callback_name,number)
+ self.msg(
+ "The callback {}{} of {} has been accepted.".format(callback_name,parameters,obj)
+ )
Source code for evennia.contrib.ingame_python.eventfuncs
+"""
+Module defining basic eventfuncs for the event system.
+
+Eventfuncs are just Python functions that can be used inside of calllbacks.
+
+"""
+
+fromevenniaimportObjectDB,ScriptDB
+fromevennia.contrib.ingame_python.utilsimportInterruptEvent
+
+
+
[docs]defdeny():
+ """
+ Deny, that is stop, the callback here.
+
+ Notes:
+ This function will raise an exception to terminate the callback
+ in a controlled way. If you use this function in an event called
+ prior to a command, the command will be cancelled as well. Good
+ situations to use the `deny()` function are in events that begins
+ by `can_`, because they usually can be cancelled as easily as that.
+
+ """
+ raiseInterruptEvent
+
+
+
[docs]defget(**kwargs):
+ """
+ Return an object with the given search option or None if None is found.
+
+ Keyword Args:
+ Any searchable data or property (id, db_key, db_location...).
+
+ Returns:
+ The object found that meet these criteria for research, or
+ None if none is found.
+
+ Notes:
+ This function is very useful to retrieve objects with a specific
+ ID. You know that room #32 exists, but you don't have it in
+ the callback variables. Quite simple:
+ room = get(id=32)
+
+ This function doesn't perform a search on objects, but a direct
+ search in the database. It's recommended to use it for objects
+ you know exist, using their IDs or other unique attributes.
+ Looking for objects by key is possible (use `db_key` as an
+ argument) but remember several objects can share the same key.
+
+ """
+ try:
+ object=ObjectDB.objects.get(**kwargs)
+ exceptObjectDB.DoesNotExist:
+ object=None
+
+ returnobject
+
+
+
[docs]defcall_event(obj,event_name,seconds=0):
+ """
+ Call the specified event in X seconds.
+
+ Args:
+ obj (Object): the typeclassed object containing the event.
+ event_name (str): the event name to be called.
+ seconds (int or float): the number of seconds to wait before calling
+ the event.
+
+ Notes:
+ This eventfunc can be used to call other events from inside of an
+ event in a given time. This will create a pause between events. This
+ will not freeze the game, and you can expect characters to move
+ around (unless you prevent them from doing so).
+
+ Variables that are accessible in your event using 'call()' will be
+ kept and passed on to the event to call.
+
+ Chained callbacks are designed for this very purpose: they
+ are never called automatically by the game, rather, they need
+ to be called from inside another event.
+
+ """
+ script=type(obj.callbacks).script
+ ifscript:
+ # If seconds is 0, call the event immediately
+ ifseconds==0:
+ locals=dict(script.ndb.current_locals)
+ obj.callbacks.call(event_name,locals=locals)
+ else:
+ # Schedule the task
+ script.set_task(seconds,obj,event_name)
[docs]classEventHandler(DefaultScript):
+
+ """
+ The event handler that contains all events in a global script.
+
+ This script shouldn't be created more than once. It contains
+ event (in a non-persistent attribute) and callbacks (in a
+ persistent attribute). The script method would help adding,
+ editing and deleting these events and callbacks.
+
+ """
+
+
[docs]defat_script_creation(self):
+ """Hook called when the script is created."""
+ self.key="event_handler"
+ self.desc="Global event handler"
+ self.persistent=True
+
+ # Permanent data to be stored
+ self.db.callbacks={}
+ self.db.to_valid=[]
+ self.db.locked=[]
+
+ # Tasks
+ self.db.tasks={}
+
+
[docs]defat_start(self):
+ """Set up the event system when starting.
+
+ Note that this hook is called every time the server restarts
+ (including when it's reloaded). This hook performs the following
+ tasks:
+
+ - Create temporarily stored events.
+ - Generate locals (individual events' namespace).
+ - Load eventfuncs, including user-defined ones.
+ - Re-schedule tasks that aren't set to fire anymore.
+ - Effectively connect the handler to the main script.
+
+ """
+ self.ndb.events={}
+ fortypeclass,name,variables,help_text,custom_call,custom_addinEVENTS:
+ self.add_event(typeclass,name,variables,help_text,custom_call,custom_add)
+
+ # Generate locals
+ self.ndb.current_locals={}
+ self.ndb.fresh_locals={}
+ addresses=["evennia.contrib.ingame_python.eventfuncs"]
+ addresses.extend(getattr(settings,"EVENTFUNCS_LOCATIONS",["world.eventfuncs"]))
+ foraddressinaddresses:
+ ifpypath_to_realpath(address):
+ self.ndb.fresh_locals.update(all_from_module(address))
+
+ # Restart the delayed tasks
+ now=datetime.now()
+ fortask_id,definitionintuple(self.db.tasks.items()):
+ future,obj,event_name,locals=definition
+ seconds=(future-now).total_seconds()
+ ifseconds<0:
+ seconds=0
+
+ delay(seconds,complete_task,task_id)
+
+ # Place the script in the CallbackHandler
+ fromevennia.contrib.ingame_pythonimporttypeclasses
+
+ CallbackHandler.script=self
+ DefaultObject.callbacks=typeclasses.EventObject.callbacks
+
+ # Create the channel if non-existent
+ try:
+ self.ndb.channel=ChannelDB.objects.get(db_key="everror")
+ exceptChannelDB.DoesNotExist:
+ self.ndb.channel=create_channel(
+ "everror",
+ desc="Event errors",
+ locks="control:false();listen:perm(Builders);send:false()",
+ )
+
+
[docs]defget_events(self,obj):
+ """
+ Return a dictionary of events on this object.
+
+ Args:
+ obj (Object or typeclass): the connected object or a general typeclass.
+
+ Returns:
+ A dictionary of the object's events.
+
+ Notes:
+ Events would define what the object can have as
+ callbacks. Note, however, that chained callbacks will not
+ appear in events and are handled separately.
+
+ You can also request the events of a typeclass, not a
+ connected object. This is useful to get the global list
+ of events for a typeclass that has no object yet.
+
+ """
+ events={}
+ all_events=self.ndb.events
+ classes=Queue()
+ ifisinstance(obj,type):
+ classes.put(obj)
+ else:
+ classes.put(type(obj))
+
+ invalid=[]
+ whilenotclasses.empty():
+ typeclass=classes.get()
+ typeclass_name=typeclass.__module__+"."+typeclass.__name__
+ forkey,etypeinall_events.get(typeclass_name,{}).items():
+ ifkeyininvalid:
+ continue
+ ifetype[0]isNone:# Invalidate
+ invalid.append(key)
+ continue
+ ifkeynotinevents:
+ events[key]=etype
+
+ # Look for the parent classes
+ forparentintypeclass.__bases__:
+ classes.put(parent)
+
+ returnevents
+
+
[docs]defget_variable(self,variable_name):
+ """
+ Return the variable defined in the locals.
+
+ This can be very useful to check the value of a variable that can be modified in an event, and whose value will be used in code. This system allows additional customization.
+
+ Args:
+ variable_name (str): the name of the variable to return.
+
+ Returns:
+ The variable if found in the locals.
+ None if not found in the locals.
+
+ Note:
+ This will return the variable from the current locals.
+ Keep in mind that locals are shared between events. As
+ every event is called one by one, this doesn't pose
+ additional problems if you get the variable right after
+ an event has been executed. If, however, you differ,
+ there's no guarantee the variable will be here or will
+ mean the same thing.
+
+ """
+ returnself.ndb.current_locals.get(variable_name)
+
+
[docs]defget_callbacks(self,obj):
+ """
+ Return a dictionary of the object's callbacks.
+
+ Args:
+ obj (Object): the connected objects.
+
+ Returns:
+ A dictionary of the object's callbacks.
+
+ Note:
+ This method can be useful to override in some contexts,
+ when several objects would share callbacks.
+
+ """
+ obj_callbacks=self.db.callbacks.get(obj,{})
+ callbacks={}
+ forcallback_name,callback_listinobj_callbacks.items():
+ new_list=[]
+ fori,callbackinenumerate(callback_list):
+ callback=dict(callback)
+ callback["obj"]=obj
+ callback["name"]=callback_name
+ callback["number"]=i
+ new_list.append(callback)
+
+ ifnew_list:
+ callbacks[callback_name]=new_list
+
+ returncallbacks
+
+
[docs]defadd_callback(self,obj,callback_name,code,author=None,valid=False,parameters=""):
+ """
+ Add the specified callback.
+
+ Args:
+ obj (Object): the Evennia typeclassed object to be extended.
+ callback_name (str): the name of the callback to add.
+ code (str): the Python code associated with this callback.
+ author (Character or Account, optional): the author of the callback.
+ valid (bool, optional): should the callback be connected?
+ parameters (str, optional): optional parameters.
+
+ Note:
+ This method doesn't check that the callback type exists.
+
+ """
+ obj_callbacks=self.db.callbacks.get(obj,{})
+ ifnotobj_callbacks:
+ self.db.callbacks[obj]={}
+ obj_callbacks=self.db.callbacks[obj]
+
+ callbacks=obj_callbacks.get(callback_name,[])
+ ifnotcallbacks:
+ obj_callbacks[callback_name]=[]
+ callbacks=obj_callbacks[callback_name]
+
+ # Add the callback in the list
+ callbacks.append(
+ {
+ "created_on":datetime.now(),
+ "author":author,
+ "valid":valid,
+ "code":code,
+ "parameters":parameters,
+ }
+ )
+
+ # If not valid, set it in 'to_valid'
+ ifnotvalid:
+ self.db.to_valid.append((obj,callback_name,len(callbacks)-1))
+
+ # Call the custom_add if needed
+ custom_add=self.get_events(obj).get(callback_name,[None,None,None,None])[3]
+ ifcustom_add:
+ custom_add(obj,callback_name,len(callbacks)-1,parameters)
+
+ # Build the definition to return (a dictionary)
+ definition=dict(callbacks[-1])
+ definition["obj"]=obj
+ definition["name"]=callback_name
+ definition["number"]=len(callbacks)-1
+ returndefinition
+
+
[docs]defedit_callback(self,obj,callback_name,number,code,author=None,valid=False):
+ """
+ Edit the specified callback.
+
+ Args:
+ obj (Object): the Evennia typeclassed object to be edited.
+ callback_name (str): the name of the callback to edit.
+ number (int): the callback number to be changed.
+ code (str): the Python code associated with this callback.
+ author (Character or Account, optional): the author of the callback.
+ valid (bool, optional): should the callback be connected?
+
+ Raises:
+ RuntimeError if the callback is locked.
+
+ Note:
+ This method doesn't check that the callback type exists.
+
+ """
+ obj_callbacks=self.db.callbacks.get(obj,{})
+ ifnotobj_callbacks:
+ self.db.callbacks[obj]={}
+ obj_callbacks=self.db.callbacks[obj]
+
+ callbacks=obj_callbacks.get(callback_name,[])
+ ifnotcallbacks:
+ obj_callbacks[callback_name]=[]
+ callbacks=obj_callbacks[callback_name]
+
+ # If locked, don't edit it
+ if(obj,callback_name,number)inself.db.locked:
+ raiseRuntimeError("this callback is locked.")
+
+ # Edit the callback
+ callbacks[number].update(
+ {"updated_on":datetime.now(),"updated_by":author,"valid":valid,"code":code}
+ )
+
+ # If not valid, set it in 'to_valid'
+ ifnotvalidand(obj,callback_name,number)notinself.db.to_valid:
+ self.db.to_valid.append((obj,callback_name,number))
+ elifvalidand(obj,callback_name,number)inself.db.to_valid:
+ self.db.to_valid.remove((obj,callback_name,number))
+
+ # Build the definition to return (a dictionary)
+ definition=dict(callbacks[number])
+ definition["obj"]=obj
+ definition["name"]=callback_name
+ definition["number"]=number
+ returndefinition
+
+
[docs]defdel_callback(self,obj,callback_name,number):
+ """
+ Delete the specified callback.
+
+ Args:
+ obj (Object): the typeclassed object containing the callback.
+ callback_name (str): the name of the callback to delete.
+ number (int): the number of the callback to delete.
+
+ Raises:
+ RuntimeError if the callback is locked.
+
+ """
+ obj_callbacks=self.db.callbacks.get(obj,{})
+ callbacks=obj_callbacks.get(callback_name,[])
+
+ # If locked, don't edit it
+ if(obj,callback_name,number)inself.db.locked:
+ raiseRuntimeError("this callback is locked.")
+
+ # Delete the callback itself
+ try:
+ code=callbacks[number]["code"]
+ exceptIndexError:
+ return
+ else:
+ logger.log_info(
+ "Deleting callback {}{} of {}:\n{}".format(callback_name,number,obj,code)
+ )
+ delcallbacks[number]
+
+ # Change IDs of callbacks to be validated
+ i=0
+ whilei<len(self.db.to_valid):
+ t_obj,t_callback_name,t_number=self.db.to_valid[i]
+ ifobjist_objandcallback_name==t_callback_name:
+ ift_number==number:
+ # Strictly equal, delete the callback
+ delself.db.to_valid[i]
+ i-=1
+ elift_number>number:
+ # Change the ID for this callback
+ self.db.to_valid.insert(i,(t_obj,t_callback_name,t_number-1))
+ delself.db.to_valid[i+1]
+ i+=1
+
+ # Update locked callback
+ fori,lineinenumerate(self.db.locked):
+ t_obj,t_callback_name,t_number=line
+ ifobjist_objandcallback_name==t_callback_name:
+ ifnumber<t_number:
+ self.db.locked[i]=(t_obj,t_callback_name,t_number-1)
+
+ # Delete time-related callbacks associated with this object
+ forscriptinobj.scripts.all():
+ ifisinstance(script,TimecallbackScript):
+ ifscript.objisobjandscript.db.callback_name==callback_name:
+ ifscript.db.number==number:
+ script.stop()
+ elifscript.db.number>number:
+ script.db.number-=1
+
+
[docs]defaccept_callback(self,obj,callback_name,number):
+ """
+ Valid a callback.
+
+ Args:
+ obj (Object): the object containing the callback.
+ callback_name (str): the name of the callback.
+ number (int): the number of the callback.
+
+ """
+ obj_callbacks=self.db.callbacks.get(obj,{})
+ callbacks=obj_callbacks.get(callback_name,[])
+
+ # Accept and connect the callback
+ callbacks[number].update({"valid":True})
+ if(obj,callback_name,number)inself.db.to_valid:
+ self.db.to_valid.remove((obj,callback_name,number))
+
+
[docs]defcall(self,obj,callback_name,*args,**kwargs):
+ """
+ Call the connected callbacks.
+
+ Args:
+ obj (Object): the Evennia typeclassed object.
+ callback_name (str): the callback name to call.
+ *args: additional variables for this callback.
+
+ Keyword Args:
+ number (int, optional): call just a specific callback.
+ parameters (str, optional): call a callback with parameters.
+ locals (dict, optional): a locals replacement.
+
+ Returns:
+ True to report the callback was called without interruption,
+ False otherwise.
+
+ """
+ # First, look for the callback type corresponding to this name
+ number=kwargs.get("number")
+ parameters=kwargs.get("parameters")
+ locals=kwargs.get("locals")
+
+ # Errors should not pass silently
+ allowed=("number","parameters","locals")
+ ifany(kforkinkwargsifknotinallowed):
+ raiseTypeError(
+ "Unknown keyword arguments were specified ""to call callbacks: {}".format(kwargs)
+ )
+
+ event=self.get_events(obj).get(callback_name)
+ iflocalsisNoneandnotevent:
+ logger.log_err(
+ "The callback {} for the object {} (typeclass "
+ "{}) can't be found".format(callback_name,obj,type(obj))
+ )
+ returnFalse
+
+ # Prepare the locals if necessary
+ iflocalsisNone:
+ locals=self.ndb.fresh_locals.copy()
+ fori,variableinenumerate(event[0]):
+ try:
+ locals[variable]=args[i]
+ exceptIndexError:
+ logger.log_trace(
+ "callback {} of {} ({}): need variable "
+ "{} in position {}".format(callback_name,obj,type(obj),variable,i)
+ )
+ returnFalse
+ else:
+ locals={key:valueforkey,valueinlocals.items()}
+
+ callbacks=self.get_callbacks(obj).get(callback_name,[])
+ ifevent:
+ custom_call=event[2]
+ ifcustom_call:
+ callbacks=custom_call(callbacks,parameters)
+
+ # Now execute all the valid callbacks linked at this address
+ self.ndb.current_locals=locals
+ fori,callbackinenumerate(callbacks):
+ ifnotcallback["valid"]:
+ continue
+
+ ifnumberisnotNoneandcallback["number"]!=number:
+ continue
+
+ try:
+ exec(callback["code"],locals,locals)
+ exceptInterruptEvent:
+ returnFalse
+ exceptException:
+ etype,evalue,tb=sys.exc_info()
+ trace=traceback.format_exception(etype,evalue,tb)
+ self.handle_error(callback,trace)
+
+ returnTrue
+
+
[docs]defhandle_error(self,callback,trace):
+ """
+ Handle an error in a callback.
+
+ Args:
+ callback (dict): the callback representation.
+ trace (list): the traceback containing the exception.
+
+ Notes:
+ This method can be useful to override to change the default
+ handling of errors. By default, the error message is sent to
+ the character who last updated the callback, if connected.
+ If not, display to the everror channel.
+
+ """
+ callback_name=callback["name"]
+ number=callback["number"]
+ obj=callback["obj"]
+ oid=obj.id
+ logger.log_err(
+ "An error occurred during the callback {} of "
+ "{} (#{}), number {}\n{}".format(callback_name,obj,oid,number+1,"\n".join(trace))
+ )
+
+ # Create the error message
+ line="|runknown|n"
+ lineno="|runknown|n"
+ forerrorintrace:
+ iferror.startswith(' File "<string>", line '):
+ res=RE_LINE_ERROR.search(error)
+ ifres:
+ lineno=int(res.group(1))
+
+ # Try to extract the line
+ try:
+ line=raw(callback["code"].splitlines()[lineno-1])
+ exceptIndexError:
+ continue
+ else:
+ break
+
+ exc=raw(trace[-1].strip("\n").splitlines()[-1])
+ err_msg="Error in {} of {} (#{})[{}], line {}:"" {}\n{}".format(
+ callback_name,obj,oid,number+1,lineno,line,exc
+ )
+
+ # Inform the last updater if connected
+ updater=callback.get("updated_by")
+ ifupdaterisNone:
+ updater=callback["created_by"]
+
+ ifupdaterandupdater.sessions.all():
+ updater.msg(err_msg)
+ else:
+ err_msg="Error in {} of {} (#{})[{}], line {}:"" {}\n{}".format(
+ callback_name,obj,oid,number+1,lineno,line,exc
+ )
+ self.ndb.channel.msg(err_msg)
+
+
[docs]defadd_event(self,typeclass,name,variables,help_text,custom_call,custom_add):
+ """
+ Add a new event for a defined typeclass.
+
+ Args:
+ typeclass (str): the path leading to the typeclass.
+ name (str): the name of the event to add.
+ variables (list of str): list of variable names for this event.
+ help_text (str): the long help text of the event.
+ custom_call (callable or None): the function to be called
+ when the event fires.
+ custom_add (callable or None): the function to be called when
+ a callback is added.
+
+ """
+ iftypeclassnotinself.ndb.events:
+ self.ndb.events[typeclass]={}
+
+ events=self.ndb.events[typeclass]
+ ifnamenotinevents:
+ events[name]=(variables,help_text,custom_call,custom_add)
+
+
[docs]defset_task(self,seconds,obj,callback_name):
+ """
+ Set and schedule a task to run.
+
+ Args:
+ seconds (int, float): the delay in seconds from now.
+ obj (Object): the typecalssed object connected to the event.
+ callback_name (str): the callback's name.
+
+ Notes:
+ This method allows to schedule a "persistent" task.
+ 'utils.delay' is called, but a copy of the task is kept in
+ the event handler, and when the script restarts (after reload),
+ the differed delay is called again.
+ The dictionary of locals is frozen and will be available
+ again when the task runs. This feature, however, is limited
+ by the database: all data cannot be saved. Lambda functions,
+ class methods, objects inside an instance and so on will
+ not be kept in the locals dictionary.
+
+ """
+ now=datetime.now()
+ delta=timedelta(seconds=seconds)
+
+ # Choose a free task_id
+ used_ids=list(self.db.tasks.keys())
+ task_id=1
+ whiletask_idinused_ids:
+ task_id+=1
+
+ # Collect and freeze current locals
+ locals={}
+ forkey,valueinself.ndb.current_locals.items():
+ try:
+ dbserialize(value)
+ exceptTypeError:
+ continue
+ else:
+ locals[key]=value
+
+ self.db.tasks[task_id]=(now+delta,obj,callback_name,locals)
+ delay(seconds,complete_task,task_id)
[docs]defat_repeat(self):
+ """
+ Call the event and reset interval.
+
+ It is necessary to restart the script to reset its interval
+ only twice after a reload. When the script has undergone
+ down time, there's usually a slight shift in game time. Once
+ the script restarts once, it will set the average time it
+ needs for all its future intervals and should not need to be
+ restarted. In short, a script that is created shouldn't need
+ to restart more than once, and a script that is reloaded should
+ restart only twice.
+
+ """
+ ifself.db.time_format:
+ # If the 'usual' time is set, use it
+ seconds=self.ndb.usual
+ ifsecondsisNone:
+ seconds,usual,details=get_next_wait(self.db.time_format)
+ self.ndb.usual=usual
+
+ ifself.interval!=seconds:
+ self.restart(interval=seconds)
+
+ ifself.db.event_nameandself.db.numberisnotNone:
+ obj=self.obj
+ ifnotobj.callbacks:
+ return
+
+ event_name=self.db.event_name
+ number=self.db.number
+ obj.callbacks.call(event_name,obj,number=number)
+
+
+# Functions to manipulate tasks
+
[docs]defcomplete_task(task_id):
+ """
+ Mark the task in the event handler as complete.
+
+ Args:
+ task_id (int): the task ID.
+
+ Note:
+ This function should be called automatically for individual tasks.
+
+ """
+ try:
+ script=ScriptDB.objects.get(db_key="event_handler")
+ exceptScriptDB.DoesNotExist:
+ logger.log_trace("Can't get the event handler.")
+ return
+
+ iftask_idnotinscript.db.tasks:
+ logger.log_err("The task #{} was scheduled, but it cannot be ""found".format(task_id))
+ return
+
+ delta,obj,callback_name,locals=script.db.tasks.pop(task_id)
+ script.call(obj,callback_name,locals=locals)
[docs]deftest_list(self):
+ """Test listing callbacks with different rights."""
+ table=self.call(CmdCallback(),"out")
+ lines=table.splitlines()[3:-1]
+ self.assertNotEqual(lines,[])
+
+ # Check that the second column only contains 0 (0) (no callback yet)
+ forlineinlines:
+ cols=line.split("|")
+ self.assertIn(cols[2].strip(),("0 (0)",""))
+
+ # Add some callback
+ self.handler.add_callback(self.exit,"traverse","pass",author=self.char1,valid=True)
+
+ # Try to obtain more details on a specific callback on exit
+ table=self.call(CmdCallback(),"out = traverse")
+ lines=table.splitlines()[3:-1]
+ self.assertEqual(len(lines),1)
+ line=lines[0]
+ cols=line.split("|")
+ self.assertIn(cols[1].strip(),("1",""))
+ self.assertIn(cols[2].strip(),(str(self.char1),""))
+ self.assertIn(cols[-1].strip(),("Yes","No",""))
+
+ # Run the same command with char2
+ # char2 shouldn't see the last column (Valid)
+ table=self.call(CmdCallback(),"out = traverse",caller=self.char2)
+ lines=table.splitlines()[3:-1]
+ self.assertEqual(len(lines),1)
+ line=lines[0]
+ cols=line.split("|")
+ self.assertEqual(cols[1].strip(),"1")
+ self.assertNotIn(cols[-1].strip(),("Yes","No"))
+
+ # In any case, display the callback
+ # The last line should be "pass" (the callback code)
+ details=self.call(CmdCallback(),"out = traverse 1")
+ self.assertEqual(details.splitlines()[-1],"pass")
+
+
[docs]deftest_add(self):
+ """Test to add an callback."""
+ self.call(CmdCallback(),"/add out = traverse")
+ editor=self.char1.ndb._eveditor
+ self.assertIsNotNone(editor)
+
+ # Edit the callback
+ editor.update_buffer(
+ dedent(
+ """
+ if character.key == "one":
+ character.msg("You can pass.")
+ else:
+ character.msg("You can't pass.")
+ deny()
+ """.strip(
+ "\n"
+ )
+ )
+ )
+ editor.save_buffer()
+ editor.quit()
+ callback=self.exit.callbacks.get("traverse")[0]
+ self.assertEqual(callback.author,self.char1)
+ self.assertEqual(callback.valid,True)
+ self.assertTrue(len(callback.code)>0)
+
+ # We're going to try the same thing but with char2
+ # char2 being a player for our test, the callback won't be validated.
+ self.call(CmdCallback(),"/add out = traverse",caller=self.char2)
+ editor=self.char2.ndb._eveditor
+ self.assertIsNotNone(editor)
+
+ # Edit the callback
+ editor.update_buffer(
+ dedent(
+ """
+ character.msg("No way.")
+ """.strip(
+ "\n"
+ )
+ )
+ )
+ editor.save_buffer()
+ editor.quit()
+ callback=self.exit.callbacks.get("traverse")[1]
+ self.assertEqual(callback.author,self.char2)
+ self.assertEqual(callback.valid,False)
+ self.assertTrue(len(callback.code)>0)
+
+
[docs]deftest_del(self):
+ """Add and remove an callback."""
+ self.handler.add_callback(self.exit,"traverse","pass",author=self.char1,valid=True)
+
+ # Try to delete the callback
+ # char2 shouldn't be allowed to do so (that's not HIS callback)
+ self.call(CmdCallback(),"/del out = traverse 1",caller=self.char2)
+ self.assertTrue(len(self.handler.get_callbacks(self.exit).get("traverse",[]))==1)
+
+ # Now, char1 should be allowed to delete it
+ self.call(CmdCallback(),"/del out = traverse 1")
+ self.assertTrue(len(self.handler.get_callbacks(self.exit).get("traverse",[]))==0)
+
+
[docs]deftest_lock(self):
+ """Test the lock of multiple editing."""
+ self.call(CmdCallback(),"/add here = time 8:00",caller=self.char2)
+ self.assertIsNotNone(self.char2.ndb._eveditor)
+
+ # Now ask char1 to edit
+ line=self.call(CmdCallback(),"/edit here = time 1")
+ self.assertIsNone(self.char1.ndb._eveditor)
+
+ # Try to delete this callback while char2 is editing it
+ line=self.call(CmdCallback(),"/del here = time 1")
+
+
[docs]deftest_accept(self):
+ """Accept an callback."""
+ self.call(CmdCallback(),"/add here = time 8:00",caller=self.char2)
+ editor=self.char2.ndb._eveditor
+ self.assertIsNotNone(editor)
+
+ # Edit the callback
+ editor.update_buffer(
+ dedent(
+ """
+ room.msg_contents("It's 8 PM, everybody up!")
+ """.strip(
+ "\n"
+ )
+ )
+ )
+ editor.save_buffer()
+ editor.quit()
+ callback=self.room1.callbacks.get("time")[0]
+ self.assertEqual(callback.valid,False)
+
+ # chars shouldn't be allowed to the callback
+ self.call(CmdCallback(),"/accept here = time 1",caller=self.char2)
+ callback=self.room1.callbacks.get("time")[0]
+ self.assertEqual(callback.valid,False)
+
+ # char1 will accept the callback
+ self.call(CmdCallback(),"/accept here = time 1")
+ callback=self.room1.callbacks.get("time")[0]
+ self.assertEqual(callback.valid,True)
+
+
+
[docs]classTestDefaultCallbacks(CommandTest):
+
+ """Test the default callbacks."""
+
+
[docs]defsetUp(self):
+ """Create the callback handler."""
+ super().setUp()
+ self.handler=create_script("evennia.contrib.ingame_python.scripts.EventHandler")
+
+ # Copy old events if necessary
+ ifOLD_EVENTS:
+ self.handler.ndb.events=dict(OLD_EVENTS)
+
+ # Alter typeclasses
+ self.char1.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventCharacter")
+ self.char2.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventCharacter")
+ self.room1.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventRoom")
+ self.room2.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventRoom")
+ self.exit.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventExit")
Source code for evennia.contrib.ingame_python.utils
+"""
+Functions to extend the event system.
+
+These functions are to be used by developers to customize events and callbacks.
+
+"""
+
+fromtextwrapimportdedent
+
+fromdjango.confimportsettings
+fromevenniaimportlogger
+fromevenniaimportScriptDB
+fromevennia.utils.createimportcreate_script
+fromevennia.utils.gametimeimportreal_seconds_untilasstandard_rsu
+fromevennia.utils.utilsimportclass_from_module
+fromevennia.contrib.custom_gametimeimportUNITS
+fromevennia.contrib.custom_gametimeimportgametime_to_realtime
+fromevennia.contrib.custom_gametimeimportreal_seconds_untilascustom_rsu
+
+# Temporary storage for events waiting for the script to be started
+EVENTS=[]
+
+
+
[docs]defget_event_handler():
+ """Return the event handler or None."""
+ try:
+ script=ScriptDB.objects.get(db_key="event_handler")
+ exceptScriptDB.DoesNotExist:
+ logger.log_trace("Can't get the event handler.")
+ script=None
+
+ returnscript
+
+
+
[docs]defregister_events(path_or_typeclass):
+ """
+ Register the events in this typeclass.
+
+ Args:
+ path_or_typeclass (str or type): the Python path leading to the
+ class containing events, or the class itself.
+
+ Returns:
+ The typeclass itself.
+
+ Notes:
+ This function will read events from the `_events` class variable
+ defined in the typeclass given in parameters. It will add
+ the events, either to the script if it exists, or to some
+ temporary storage, waiting for the script to be initialized.
+
+ """
+ ifisinstance(path_or_typeclass,str):
+ typeclass=class_from_module(path_or_typeclass)
+ else:
+ typeclass=path_or_typeclass
+
+ typeclass_name=typeclass.__module__+"."+typeclass.__name__
+ try:
+ storage=ScriptDB.objects.get(db_key="event_handler")
+ assertstorage.is_active
+ assertstorage.ndb.eventsisnotNone
+ except(ScriptDB.DoesNotExist,AssertionError):
+ storage=EVENTS
+
+ # If the script is started, add the event directly.
+ # Otherwise, add it to the temporary storage.
+ forname,tupingetattr(typeclass,"_events",{}).items():
+ iflen(tup)==4:
+ variables,help_text,custom_call,custom_add=tup
+ eliflen(tup)==3:
+ variables,help_text,custom_call=tup
+ custom_add=None
+ eliflen(tup)==2:
+ variables,help_text=tup
+ custom_call=None
+ custom_add=None
+ else:
+ variables=help_text=custom_call=custom_add=None
+
+ ifisinstance(storage,list):
+ storage.append((typeclass_name,name,variables,help_text,custom_call,custom_add))
+ else:
+ storage.add_event(typeclass_name,name,variables,help_text,custom_call,custom_add)
+
+ returntypeclass
+
+
+# Custom callbacks for specific event types
+
+
+
[docs]defget_next_wait(format):
+ """
+ Get the length of time in seconds before format.
+
+ Args:
+ format (str): a time format matching the set calendar.
+
+ Returns:
+ until (int or float): the number of seconds until the event.
+ usual (int or float): the usual number of seconds between events.
+ format (str): a string format representing the time.
+
+ Notes:
+ The time format could be something like "2018-01-08 12:00". The
+ number of units set in the calendar affects the way seconds are
+ calculated.
+
+ """
+ calendar=getattr(settings,"EVENTS_CALENDAR",None)
+ ifcalendarisNone:
+ logger.log_err(
+ "A time-related event has been set whereas "
+ "the gametime calendar has not been set in the settings."
+ )
+ return
+ elifcalendar=="standard":
+ rsu=standard_rsu
+ units=["min","hour","day","month","year"]
+ elifcalendar=="custom":
+ rsu=custom_rsu
+ back=dict([(value,name)forname,valueinUNITS.items()])
+ sorted_units=sorted(back.items())
+ delsorted_units[0]
+ units=[nforv,ninsorted_units]
+
+ params={}
+ fordelimiterin("-",":"):
+ format=format.replace(delimiter," ")
+
+ pieces=list(reversed(format.split()))
+ details=[]
+ i=0
+ forunameinunits:
+ try:
+ piece=pieces[i]
+ exceptIndexError:
+ break
+
+ ifnotpiece.isdigit():
+ logger.log_trace(
+ "The time specified '{}' in {} isn't ""a valid number".format(piece,format)
+ )
+ return
+
+ # Convert the piece to int
+ piece=int(piece)
+ params[uname]=piece
+ details.append("{}={}".format(uname,piece))
+ ifi<len(units):
+ next_unit=units[i+1]
+ else:
+ next_unit=None
+ i+=1
+
+ params["sec"]=0
+ details=" ".join(details)
+ until=rsu(**params)
+ usual=-1
+ ifnext_unit:
+ kwargs={next_unit:1}
+ usual=gametime_to_realtime(**kwargs)
+ returnuntil,usual,details
+
+
+
[docs]deftime_event(obj,event_name,number,parameters):
+ """
+ Create a time-related event.
+
+ Args:
+ obj (Object): the object on which sits the event.
+ event_name (str): the event's name.
+ number (int): the number of the event.
+ parameters (str): the parameter of the event.
+
+ """
+ seconds,usual,key=get_next_wait(parameters)
+ script=create_script(
+ "evennia.contrib.ingame_python.scripts.TimeEventScript",interval=seconds,obj=obj
+ )
+ script.key=key
+ script.desc="event on {}".format(key)
+ script.db.time_format=parameters
+ script.db.number=number
+ script.ndb.usual=usual
+
+
+
[docs]defkeyword_event(callbacks,parameters):
+ """
+ Custom call for events with keywords (like push, or pull, or turn...).
+
+ Args:
+ callbacks (list of dict): the list of callbacks to be called.
+ parameters (str): the actual parameters entered to trigger the callback.
+
+ Returns:
+ A list containing the callback dictionaries to be called.
+
+ Notes:
+ This function should be imported and added as a custom_call
+ parameter to add the event when the event supports keywords
+ as parameters. Keywords in parameters are one or more words
+ separated by a comma. For instance, a 'push 1, one' callback can
+ be set to trigger when the player 'push 1' or 'push one'.
+
+ """
+ key=parameters.strip().lower()
+ to_call=[]
+ forcallbackincallbacks:
+ keys=callback["parameters"]
+ ifnotkeysorkeyin[p.strip().lower()forpinkeys.split(",")]:
+ to_call.append(callback)
+
+ returnto_call
+
+
+
[docs]defphrase_event(callbacks,parameters):
+ """
+ Custom call for events with keywords in sentences (like say or whisper).
+
+ Args:
+ callbacks (list of dict): the list of callbacks to be called.
+ parameters (str): the actual parameters entered to trigger the callback.
+
+ Returns:
+ A list containing the callback dictionaries to be called.
+
+ Notes:
+ This function should be imported and added as a custom_call
+ parameter to add the event when the event supports keywords
+ in phrases as parameters. Keywords in parameters are one or more
+ words separated by a comma. For instance, a 'say yes, okay' callback
+ can be set to trigger when the player says something containing
+ either "yes" or "okay" (maybe 'say I don't like it, but okay').
+
+ """
+ phrase=parameters.strip().lower()
+ # Remove punctuation marks
+ punctuations=',.";?!'
+ forpinpunctuations:
+ phrase=phrase.replace(p," ")
+ words=phrase.split()
+ words=[w.strip("' ")forwinwordsifw.strip("' ")]
+ to_call=[]
+ forcallbackincallbacks:
+ keys=callback["parameters"]
+ ifnotkeysorany(key.strip().lower()inwordsforkeyinkeys.split(",")):
+ to_call.append(callback)
+
+ returnto_call
+
+
+
[docs]classInterruptEvent(RuntimeError):
+
+ """
+ Interrupt the current event.
+
+ You shouldn't have to use this exception directly, probably use the
+ `deny()` function that handles it instead.
+
+ """
+
+ pass
+"""
+In-Game Mail system
+
+Evennia Contribution - grungies1138 2016
+
+A simple Brandymail style @mail system that uses the Msg class from Evennia
+Core. It has two Commands, both of which can be used on their own:
+ - CmdMail - this should sit on the Account cmdset and makes the @mail command
+ available both IC and OOC. Mails will always go to Accounts (other players).
+ - CmdMailCharacter - this should sit on the Character cmdset and makes the @mail
+ command ONLY available when puppeting a character. Mails will be sent to other
+ Characters only and will not be available when OOC.
+ - If adding *both* commands to their respective cmdsets, you'll get two separate
+ IC and OOC mailing systems, with different lists of mail for IC and OOC modes.
+
+Installation:
+
+Install one or both of the following (see above):
+
+- CmdMail (IC + OOC mail, sent between players)
+
+ # mygame/commands/default_cmds.py
+
+ from evennia.contrib import mail
+
+ # in AccountCmdSet.at_cmdset_creation:
+ self.add(mail.CmdMail())
+
+- CmdMailCharacter (optional, IC only mail, sent between characters)
+
+ # mygame/commands/default_cmds.py
+
+ from evennia.contrib import mail
+
+ # in CharacterCmdSet.at_cmdset_creation:
+ self.add(mail.CmdMailCharacter())
+
+Once installed, use `help mail` in game for help with the mail command. Use
+@ic/@ooc to switch in and out of IC/OOC modes.
+
+"""
+
+importre
+fromevenniaimportObjectDB,AccountDB
+fromevenniaimportdefault_cmds
+fromevennia.utilsimportcreate,evtable,make_iter,inherits_from,datetime_format
+fromevennia.comms.modelsimportMsg
+
+
+_HEAD_CHAR="|015-|n"
+_SUB_HEAD_CHAR="-"
+_WIDTH=78
+
+
+
[docs]classCmdMail(default_cmds.MuxAccountCommand):
+ """
+ Communicate with others by sending mail.
+
+ Usage:
+ @mail - Displays all the mail an account has in their mailbox
+ @mail <#> - Displays a specific message
+ @mail <accounts>=<subject>/<message>
+ - Sends a message to the comma separated list of accounts.
+ @mail/delete <#> - Deletes a specific message
+ @mail/forward <account list>=<#>[/<Message>]
+ - Forwards an existing message to the specified list of accounts,
+ original message is delivered with optional Message prepended.
+ @mail/reply <#>=<message>
+ - Replies to a message #. Prepends message to the original
+ message text.
+ Switches:
+ delete - deletes a message
+ forward - forward a received message to another object with an optional message attached.
+ reply - Replies to a received message, appending the original message to the bottom.
+ Examples:
+ @mail 2
+ @mail Griatch=New mail/Hey man, I am sending you a message!
+ @mail/delete 6
+ @mail/forward feend78 Griatch=4/You guys should read this.
+ @mail/reply 9=Thanks for the info!
+
+ """
+
+ key="@mail"
+ aliases=["mail"]
+ lock="cmd:all()"
+ help_category="General"
+
+
[docs]defparse(self):
+ """
+ Add convenience check to know if caller is an Account or not since this cmd
+ will be able to add to either Object- or Account level.
+
+ """
+ super().parse()
+ self.caller_is_account=bool(
+ inherits_from(self.caller,"evennia.accounts.accounts.DefaultAccount")
+ )
+
+
[docs]defsearch_targets(self,namelist):
+ """
+ Search a list of targets of the same type as caller.
+
+ Args:
+ caller (Object or Account): The type of object to search.
+ namelist (list): List of strings for objects to search for.
+
+ Returns:
+ targetlist (Queryset): Any target matches.
+
+ """
+ nameregex=r"|".join(r"^%s$"%re.escape(name)fornameinmake_iter(namelist))
+ ifself.caller_is_account:
+ matches=AccountDB.objects.filter(username__iregex=nameregex)
+ else:
+ matches=ObjectDB.objects.filter(db_key__iregex=nameregex)
+ returnmatches
+
+
[docs]defget_all_mail(self):
+ """
+ Returns a list of all the messages where the caller is a recipient. These
+ are all messages tagged with tags of the `mail` category.
+
+ Returns:
+ messages (QuerySet): Matching Msg objects.
+
+ """
+ ifself.caller_is_account:
+ returnMsg.objects.get_by_tag(category="mail").filter(db_receivers_accounts=self.caller)
+ else:
+ returnMsg.objects.get_by_tag(category="mail").filter(db_receivers_objects=self.caller)
+
+
[docs]defsend_mail(self,recipients,subject,message,caller):
+ """
+ Function for sending new mail. Also useful for sending notifications
+ from objects or systems.
+
+ Args:
+ recipients (list): list of Account or Character objects to receive
+ the newly created mails.
+ subject (str): The header or subject of the message to be delivered.
+ message (str): The body of the message being sent.
+ caller (obj): The object (or Account or Character) that is sending the message.
+
+ """
+ forrecipientinrecipients:
+ recipient.msg("You have received a new @mail from %s"%caller)
+ new_message=create.create_message(
+ self.caller,message,receivers=recipient,header=subject
+ )
+ new_message.tags.add("new",category="mail")
+
+ ifrecipients:
+ caller.msg("You sent your message.")
+ return
+ else:
+ caller.msg("No valid target(s) found. Cannot send message.")
+ return
+
+
[docs]deffunc(self):
+ """
+ Do the main command functionality
+ """
+
+ subject=""
+ body=""
+
+ ifself.switchesorself.args:
+ if"delete"inself.switchesor"del"inself.switches:
+ try:
+ ifnotself.lhs:
+ self.caller.msg("No Message ID given. Unable to delete.")
+ return
+ else:
+ all_mail=self.get_all_mail()
+ mind_max=max(0,all_mail.count()-1)
+ mind=max(0,min(mind_max,int(self.lhs)-1))
+ ifall_mail[mind]:
+ mail=all_mail[mind]
+ question="Delete message {} ({}) [Y]/N?".format(mind+1,mail.header)
+ ret=yield(question)
+ # handle not ret, it will be None during unit testing
+ ifnotretorret.strip().upper()notin("N","No"):
+ all_mail[mind].delete()
+ self.caller.msg("Message %s deleted"%(mind+1,))
+ else:
+ self.caller.msg("Message not deleted.")
+ else:
+ raiseIndexError
+ exceptIndexError:
+ self.caller.msg("That message does not exist.")
+ exceptValueError:
+ self.caller.msg("Usage: @mail/delete <message ID>")
+ elif"forward"inself.switchesor"fwd"inself.switches:
+ try:
+ ifnotself.rhs:
+ self.caller.msg(
+ "Cannot forward a message without a target list. ""Please try again."
+ )
+ return
+ elifnotself.lhs:
+ self.caller.msg("You must define a message to forward.")
+ return
+ else:
+ all_mail=self.get_all_mail()
+ mind_max=max(0,all_mail.count()-1)
+ if"/"inself.rhs:
+ message_number,message=self.rhs.split("/",1)
+ mind=max(0,min(mind_max,int(message_number)-1))
+
+ ifall_mail[mind]:
+ old_message=all_mail[mind]
+
+ self.send_mail(
+ self.search_targets(self.lhslist),
+ "FWD: "+old_message.header,
+ message
+ +"\n---- Original Message ----\n"
+ +old_message.message,
+ self.caller,
+ )
+ self.caller.msg("Message forwarded.")
+ else:
+ raiseIndexError
+ else:
+ mind=max(0,min(mind_max,int(self.rhs)-1))
+ ifall_mail[mind]:
+ old_message=all_mail[mind]
+ self.send_mail(
+ self.search_targets(self.lhslist),
+ "FWD: "+old_message.header,
+ "\n---- Original Message ----\n"+old_message.message,
+ self.caller,
+ )
+ self.caller.msg("Message forwarded.")
+ old_message.tags.remove("new",category="mail")
+ old_message.tags.add("fwd",category="mail")
+ else:
+ raiseIndexError
+ exceptIndexError:
+ self.caller.msg("Message does not exist.")
+ exceptValueError:
+ self.caller.msg("Usage: @mail/forward <account list>=<#>[/<Message>]")
+ elif"reply"inself.switchesor"rep"inself.switches:
+ try:
+ ifnotself.rhs:
+ self.caller.msg("You must define a message to reply to.")
+ return
+ elifnotself.lhs:
+ self.caller.msg("You must supply a reply message")
+ return
+ else:
+ all_mail=self.get_all_mail()
+ mind_max=max(0,all_mail.count()-1)
+ mind=max(0,min(mind_max,int(self.lhs)-1))
+ ifall_mail[mind]:
+ old_message=all_mail[mind]
+ self.send_mail(
+ old_message.senders,
+ "RE: "+old_message.header,
+ self.rhs+"\n---- Original Message ----\n"+old_message.message,
+ self.caller,
+ )
+ old_message.tags.remove("new",category="mail")
+ old_message.tags.add("-",category="mail")
+ return
+ else:
+ raiseIndexError
+ exceptIndexError:
+ self.caller.msg("Message does not exist.")
+ exceptValueError:
+ self.caller.msg("Usage: @mail/reply <#>=<message>")
+ else:
+ # normal send
+ ifself.rhs:
+ if"/"inself.rhs:
+ subject,body=self.rhs.split("/",1)
+ else:
+ body=self.rhs
+ self.send_mail(self.search_targets(self.lhslist),subject,body,self.caller)
+ else:
+ all_mail=self.get_all_mail()
+ mind_max=max(0,all_mail.count()-1)
+ try:
+ mind=max(0,min(mind_max,int(self.lhs)-1))
+ message=all_mail[mind]
+ except(ValueError,IndexError):
+ self.caller.msg("'%s' is not a valid mail id."%self.lhs)
+ return
+
+ messageForm=[]
+ ifmessage:
+ messageForm.append(_HEAD_CHAR*_WIDTH)
+ messageForm.append(
+ "|wFrom:|n %s"%(message.senders[0].get_display_name(self.caller))
+ )
+ # note that we cannot use %-d format here since Windows does not support it
+ day=message.db_date_created.day
+ messageForm.append(
+ "|wSent:|n %s"
+ %message.db_date_created.strftime(f"%b {day}, %Y - %H:%M:%S")
+ )
+ messageForm.append("|wSubject:|n %s"%message.header)
+ messageForm.append(_SUB_HEAD_CHAR*_WIDTH)
+ messageForm.append(message.message)
+ messageForm.append(_HEAD_CHAR*_WIDTH)
+ self.caller.msg("\n".join(messageForm))
+ message.tags.remove("new",category="mail")
+ message.tags.add("-",category="mail")
+
+ else:
+ # list messages
+ messages=self.get_all_mail()
+
+ ifmessages:
+ table=evtable.EvTable(
+ "|wID|n",
+ "|wFrom|n",
+ "|wSubject|n",
+ "|wArrived|n",
+ "",
+ table=None,
+ border="header",
+ header_line_char=_SUB_HEAD_CHAR,
+ width=_WIDTH,
+ )
+ index=1
+ formessageinmessages:
+ status=str(message.db_tags.last().db_key.upper())
+ ifstatus=="NEW":
+ status="|gNEW|n"
+
+ table.add_row(
+ index,
+ message.senders[0].get_display_name(self.caller),
+ message.header,
+ datetime_format(message.db_date_created),
+ status,
+ )
+ index+=1
+
+ table.reformat_column(0,width=6)
+ table.reformat_column(1,width=18)
+ table.reformat_column(2,width=34)
+ table.reformat_column(3,width=13)
+ table.reformat_column(4,width=7)
+
+ self.caller.msg(_HEAD_CHAR*_WIDTH)
+ self.caller.msg(str(table))
+ self.caller.msg(_HEAD_CHAR*_WIDTH)
+ else:
+ self.caller.msg("There are no messages in your inbox.")
+
+
+# character - level version of the command
+
+
+
+"""
+Evennia Mutltidescer
+
+Contrib - Griatch 2016
+
+A "multidescer" is a concept from the MUSH world. It allows for
+creating, managing and switching between multiple character
+descriptions. This multidescer will not require any changes to the
+Character class, rather it will use the `multidescs` Attribute (a
+list) and create it if it does not exist.
+
+This contrib also works well together with the rpsystem contrib (which
+also adds the short descriptions and the `sdesc` command).
+
+
+Installation:
+
+Edit `mygame/commands/default_cmdsets.py` and add
+`from evennia.contrib.multidescer import CmdMultiDesc` to the top.
+
+Next, look up the `at_cmdset_create` method of the `CharacterCmdSet`
+class and add a line `self.add(CmdMultiDesc())` to the end
+of it.
+
+Reload the server and you should have the +desc command available (it
+will replace the default `desc` command).
+
+"""
+importre
+fromevenniaimportdefault_cmds
+fromevennia.utils.utilsimportcrop
+fromevennia.utils.eveditorimportEvEditor
+
+
+# regex for the set functionality
+_RE_KEYS=re.compile(r"([\w\s]+)(?:\+*?)",re.U+re.I)
+
+
+# Helper functions for the Command
+
+
+
[docs]classDescValidateError(ValueError):
+ "Used for tracebacks from desc systems"
+ pass
+
+
+def_update_store(caller,key=None,desc=None,delete=False,swapkey=None):
+ """
+ Helper function for updating the database store.
+
+ Args:
+ caller (Object): The caller of the command.
+ key (str): Description identifier
+ desc (str): Description text.
+ delete (bool): Delete given key.
+ swapkey (str): Swap list positions of `key` and this key.
+
+ """
+ ifnotcaller.db.multidesc:
+ # initialize the multidesc attribute
+ caller.db.multidesc=[("caller",caller.db.descor"")]
+ ifnotkey:
+ return
+ lokey=key.lower()
+ match=[indforind,tupinenumerate(caller.db.multidesc)iftup[0]==lokey]
+ ifmatch:
+ idesc=match[0]
+ ifdelete:
+ # delete entry
+ delcaller.db.multidesc[idesc]
+ elifswapkey:
+ # swap positions
+ loswapkey=swapkey.lower()
+ swapmatch=[indforind,tupinenumerate(caller.db.multidesc)iftup[0]==loswapkey]
+ ifswapmatch:
+ iswap=swapmatch[0]
+ ifidesc==iswap:
+ raiseDescValidateError("Swapping a key with itself does nothing.")
+ temp=caller.db.multidesc[idesc]
+ caller.db.multidesc[idesc]=caller.db.multidesc[iswap]
+ caller.db.multidesc[iswap]=temp
+ else:
+ raiseDescValidateError("Description key '|w%s|n' not found."%swapkey)
+ elifdesc:
+ # update in-place
+ caller.db.multidesc[idesc]=(lokey,desc)
+ else:
+ raiseDescValidateError("No description was set.")
+ else:
+ # no matching key
+ ifdeleteorswapkey:
+ raiseDescValidateError("Description key '|w%s|n' not found."%key)
+ elifdesc:
+ # insert new at the top of the stack
+ caller.db.multidesc.insert(0,(lokey,desc))
+ else:
+ raiseDescValidateError("No description was set.")
+
+
+# eveditor save/load/quit functions
+
+
+def_save_editor(caller,buffer):
+ "Called when the editor saves its contents"
+ key=caller.db._multidesc_editkey
+ _update_store(caller,key,buffer)
+ caller.msg("Saved description to key '%s'."%key)
+ returnTrue
+
+
+def_load_editor(caller):
+ "Called when the editor loads contents"
+ key=caller.db._multidesc_editkey
+ match=[indforind,tupinenumerate(caller.db.multidesc)iftup[0]==key]
+ ifmatch:
+ returncaller.db.multidesc[match[0]][1]
+ return""
+
+
+def_quit_editor(caller):
+ "Called when the editor quits"
+ delcaller.db._multidesc_editkey
+ caller.msg("Exited editor.")
+
+
+# The actual command class
+
+
+
[docs]classCmdMultiDesc(default_cmds.MuxCommand):
+ """
+ Manage multiple descriptions
+
+ Usage:
+ +desc [key] - show current desc desc with <key>
+ +desc <key> = <text> - add/replace desc with <key>
+ +desc/list - list descriptions (abbreviated)
+ +desc/list/full - list descriptions (full texts)
+ +desc/edit <key> - add/edit desc <key> in line editor
+ +desc/del <key> - delete desc <key>
+ +desc/swap <key1>-<key2> - swap positions of <key1> and <key2> in list
+ +desc/set <key> [+key+...] - set desc as default or combine multiple descs
+
+ Notes:
+ When combining multiple descs with +desc/set <key> + <key2> + ...,
+ any keys not matching an actual description will be inserted
+ as plain text. Use e.g. ansi line break ||/ to add a new
+ paragraph and + + or ansi space ||_ to add extra whitespace.
+
+ """
+
+ key="+desc"
+ aliases=["desc"]
+ locks="cmd:all()"
+ help_category="General"
+
+
[docs]deffunc(self):
+ """
+ Implements the multidescer. We will use `db.desc` for the
+ description in use and `db.multidesc` to store all descriptions.
+ """
+
+ caller=self.caller
+ args=self.args.strip()
+ switches=self.switches
+
+ try:
+ if"list"inswitchesor"all"inswitches:
+ # list all stored descriptions, either in full or cropped.
+ # Note that we list starting from 1, not from 0.
+ _update_store(caller)
+ do_crop="full"notinswitches
+ ifdo_crop:
+ outtext=[
+ "|w%s:|n %s"%(key,crop(desc))forkey,descincaller.db.multidesc
+ ]
+ else:
+ outtext=[
+ "\n|w%s:|n|n\n%s\n%s"%(key,"-"*(len(key)+1),desc)
+ forkey,descincaller.db.multidesc
+ ]
+
+ caller.msg("|wStored descs:|n\n"+"\n".join(outtext))
+ return
+
+ elif"edit"inswitches:
+ # Use the eveditor to edit/create the named description
+ ifnotargs:
+ caller.msg("Usage: %s/edit key"%self.key)
+ return
+
+ # this is used by the editor to know what to edit; it's deleted automatically
+ caller.db._multidesc_editkey=args
+ # start the editor
+ EvEditor(
+ caller,
+ loadfunc=_load_editor,
+ savefunc=_save_editor,
+ quitfunc=_quit_editor,
+ key="multidesc editor",
+ persistent=True,
+ )
+
+ elif"delete"inswitchesor"del"inswitches:
+ # delete a multidesc entry.
+ ifnotargs:
+ caller.msg("Usage: %s/delete key"%self.key)
+ return
+ _update_store(caller,args,delete=True)
+ caller.msg("Deleted description with key '%s'."%args)
+
+ elif"swap"inswitchesor"switch"inswitchesor"reorder"inswitches:
+ # Reorder list by swapping two entries. We expect numbers starting from 1
+ keys=[argforarginargs.split("-",1)]
+ ifnotlen(keys)==2:
+ caller.msg("Usage: %s/swap key1-key2"%self.key)
+ return
+ key1,key2=keys
+ # perform the swap
+ _update_store(caller,key1,swapkey=key2)
+ caller.msg("Swapped descs '%s' and '%s'."%(key1,key2))
+
+ elif"set"inswitches:
+ # switches one (or more) of the multidescs to be the "active" description
+ _update_store(caller)
+ ifnotargs:
+ caller.msg("Usage: %s/set key [+ key2 + key3 + ...]"%self.key)
+ return
+ new_desc=[]
+ multidesc=caller.db.multidesc
+ forkeyinargs.split("+"):
+ notfound=True
+ lokey=key.strip().lower()
+ formkey,descinmultidesc:
+ iflokey==mkey:
+ new_desc.append(desc)
+ notfound=False
+ continue
+ ifnotfound:
+ # if we get here, there is no desc match, we add it as a normal string
+ new_desc.append(key)
+ new_desc="".join(new_desc)
+ caller.db.desc=new_desc
+ caller.msg("%s\n\n|wThe above was set as the current description.|n"%new_desc)
+
+ elifself.rhsor"add"inswitches:
+ # add text directly to a new entry or an existing one.
+ ifnot(self.lhsandself.rhs):
+ caller.msg("Usage: %s/add key = description"%self.key)
+ return
+ key,desc=self.lhs,self.rhs
+ _update_store(caller,key,desc)
+ caller.msg("Stored description '%s': \"%s\""%(key,crop(desc)))
+
+ else:
+ # display the current description or a numbered description
+ _update_store(caller)
+ ifargs:
+ key=args.lower()
+ multidesc=caller.db.multidesc
+ formkey,descinmultidesc:
+ ifkey==mkey:
+ caller.msg("|wDecsription %s:|n\n%s"%(key,desc))
+ return
+ caller.msg("Description key '%s' not found."%key)
+ else:
+ caller.msg("|wCurrent desc:|n\n%s"%caller.db.desc)
+
+ exceptDescValidateErroraserr:
+ # This is triggered by _key_to_index
+ caller.msg(err)
+"""
+Puzzles System - Provides a typeclass and commands for
+objects that can be combined (i.e. 'use'd) to produce
+new objects.
+
+Evennia contribution - Henddher 2018
+
+A Puzzle is a recipe of what objects (aka parts) must
+be combined by a player so a new set of objects
+(aka results) are automatically created.
+
+Consider this simple Puzzle:
+
+ orange, mango, yogurt, blender = fruit smoothie
+
+As a Builder:
+
+ @create/drop orange
+ @create/drop mango
+ @create/drop yogurt
+ @create/drop blender
+ @create/drop fruit smoothie
+
+ @puzzle smoothie, orange, mango, yogurt, blender = fruit smoothie
+ ...
+ Puzzle smoothie(#1234) created successfuly.
+
+ @destroy/force orange, mango, yogurt, blender, fruit smoothie
+
+ @armpuzzle #1234
+ Part orange is spawned at ...
+ Part mango is spawned at ...
+ ....
+ Puzzle smoothie(#1234) has been armed successfully
+
+As Player:
+
+ use orange, mango, yogurt, blender
+ ...
+ Genius, you blended all fruits to create a fruit smoothie!
+
+Details:
+
+Puzzles are created from existing objects. The given
+objects are introspected to create prototypes for the
+puzzle parts and results. These prototypes become the
+puzzle recipe. (See PuzzleRecipe and @puzzle
+command). Once the recipe is created, all parts and result
+can be disposed (i.e. destroyed).
+
+At a later time, a Builder or a Script can arm the puzzle
+and spawn all puzzle parts in their respective
+locations (See @armpuzzle).
+
+A regular player can collect the puzzle parts and combine
+them (See use command). If player has specified
+all pieces, the puzzle is considered solved and all
+its puzzle parts are destroyed while the puzzle results
+are spawened on their corresponding location.
+
+Installation:
+
+Add the PuzzleSystemCmdSet to all players.
+Alternatively:
+
+ @py self.cmdset.add('evennia.contrib.puzzles.PuzzleSystemCmdSet')
+
+"""
+
+importitertools
+fromrandomimportchoice
+fromevenniaimportcreate_script
+fromevenniaimportCmdSet
+fromevenniaimportDefaultScript
+fromevenniaimportDefaultCharacter
+fromevenniaimportDefaultRoom
+fromevenniaimportDefaultExit
+fromevennia.commands.default.muxcommandimportMuxCommand
+fromevennia.utils.utilsimportinherits_from
+fromevennia.utilsimportsearch,utils,logger
+fromevennia.prototypes.spawnerimportspawn
+
+# Tag used by puzzles
+_PUZZLES_TAG_CATEGORY="puzzles"
+_PUZZLES_TAG_RECIPE="puzzle_recipe"
+# puzzle part and puzzle result
+_PUZZLES_TAG_MEMBER="puzzle_member"
+
+_PUZZLE_DEFAULT_FAIL_USE_MESSAGE="You try to utilize %s but nothing happens ... something amiss?"
+_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE="You are a Genius!!!"
+_PUZZLE_DEFAULT_SUCCESS_USE_LOCATION_MESSAGE="|c{caller}|n performs some kind of tribal dance and |y{result_names}|n seems to appear from thin air"
+
+# ----------- UTILITY FUNCTIONS ------------
+
+
+
[docs]defproto_def(obj,with_tags=True):
+ """
+ Basic properties needed to spawn
+ and compare recipe with candidate part
+ """
+ protodef={
+ # TODO: Don't we need to honor ALL properties? attributes, contents, etc.
+ "prototype_key":"%s(%s)"%(obj.key,obj.dbref),
+ "key":obj.key,
+ "typeclass":obj.typeclass_path,
+ "desc":obj.db.desc,
+ "location":obj.location,
+ "home":obj.home,
+ "locks":";".join(obj.locks.all()),
+ "permissions":obj.permissions.all()[:],
+ }
+ ifwith_tags:
+ tags=obj.tags.all(return_key_and_category=True)
+ tags=[(t[0],t[1],None)fortintags]
+ tags.append((_PUZZLES_TAG_MEMBER,_PUZZLES_TAG_CATEGORY,None))
+ protodef["tags"]=tags
+ returnprotodef
+
+
+
[docs]defmaskout_protodef(protodef,mask):
+ """
+ Returns a new protodef after removing protodef values based on mask
+ """
+ protodef=dict(protodef)
+ forminmask:
+ ifminprotodef:
+ protodef.pop(m)
+ returnprotodef
[docs]classCmdCreatePuzzleRecipe(MuxCommand):
+ """
+ Creates a puzzle recipe. A puzzle consists of puzzle-parts that
+ the player can 'use' together to create a specified result.
+
+ Usage:
+ @puzzle name,<part1[,part2,...>] = <result1[,result2,...]>
+
+ Example:
+ create/drop balloon
+ create/drop glass of water
+ create/drop water balloon
+ @puzzle waterballon,balloon,glass of water = water balloon
+ @del ballon, glass of water, water balloon
+ @armpuzzle #1
+
+ Notes:
+ Each part and result are objects that must (temporarily) exist and be placed in their
+ corresponding location in order to create the puzzle. After the creation of the puzzle,
+ these objects are not needed anymore and can be deleted. Components of the puzzle
+ will be re-created by use of the `@armpuzzle` command later.
+
+ """
+
+ key="@puzzle"
+ aliases="@puzzlerecipe"
+ locks="cmd:perm(puzzle) or perm(Builder)"
+ help_category="Puzzles"
+
+ confirm=True
+ default_confirm="no"
+
+
[docs]deffunc(self):
+ caller=self.caller
+
+ iflen(self.lhslist)<2ornotself.rhs:
+ string="Usage: @puzzle name,<part1[,...]> = <result1[,...]>"
+ caller.msg(string)
+ return
+
+ puzzle_name=self.lhslist[0]
+ iflen(puzzle_name)==0:
+ caller.msg("Invalid puzzle name %r."%puzzle_name)
+ return
+
+ # if there is another puzzle with same name
+ # warn user that parts and results will be
+ # interchangable
+ _puzzles=search.search_script_attribute(key="puzzle_name",value=puzzle_name)
+ _puzzles=list(filter(lambdap:isinstance(p,PuzzleRecipe),_puzzles))
+ if_puzzles:
+ confirm=(
+ "There are %d puzzles with the same name.\n"%len(_puzzles)
+ +"Its parts and results will be interchangeable.\n"
+ +"Continue yes/[no]? "
+ )
+ answer=""
+ whileanswer.strip().lower()notin("y","yes","n","no"):
+ answer=yield(confirm)
+ answer=self.default_confirmifanswer==""elseanswer
+ ifanswer.strip().lower()in("n","no"):
+ caller.msg("Cancelled: no puzzle created.")
+ return
+
+ defis_valid_obj_location(obj):
+ valid=True
+ # Rooms are the only valid locations.
+ # TODO: other valid locations could be added here.
+ # Certain locations can be handled accordingly: e.g,
+ # a part is located in a character's inventory,
+ # perhaps will translate into the player character
+ # having the part in his/her inventory while being
+ # located in the same room where the builder was
+ # located.
+ # Parts and results may have different valid locations
+ ifnotinherits_from(obj.location,DefaultRoom):
+ caller.msg("Invalid location for %s"%(obj.key))
+ valid=False
+ returnvalid
+
+ defis_valid_part_location(part):
+ returnis_valid_obj_location(part)
+
+ defis_valid_result_location(part):
+ returnis_valid_obj_location(part)
+
+ defis_valid_inheritance(obj):
+ valid=(
+ notinherits_from(obj,DefaultCharacter)
+ andnotinherits_from(obj,DefaultRoom)
+ andnotinherits_from(obj,DefaultExit)
+ )
+ ifnotvalid:
+ caller.msg("Invalid typeclass for %s"%(obj))
+ returnvalid
+
+ defis_valid_part(part):
+ returnis_valid_inheritance(part)andis_valid_part_location(part)
+
+ defis_valid_result(result):
+ returnis_valid_inheritance(result)andis_valid_result_location(result)
+
+ parts=[]
+ forobjnameinself.lhslist[1:]:
+ obj=caller.search(objname)
+ ifnotobj:
+ return
+ ifnotis_valid_part(obj):
+ return
+ parts.append(obj)
+
+ results=[]
+ forobjnameinself.rhslist:
+ obj=caller.search(objname)
+ ifnotobj:
+ return
+ ifnotis_valid_result(obj):
+ return
+ results.append(obj)
+
+ forpartinparts:
+ caller.msg("Part %s(%s)"%(part.name,part.dbref))
+
+ forresultinresults:
+ caller.msg("Result %s(%s)"%(result.name,result.dbref))
+
+ proto_parts=[proto_def(obj)forobjinparts]
+ proto_results=[proto_def(obj)forobjinresults]
+
+ puzzle=create_script(PuzzleRecipe,key=puzzle_name,persistent=True)
+ puzzle.save_recipe(puzzle_name,proto_parts,proto_results)
+ puzzle.locks.add("control:id(%s) or perm(Builder)"%caller.dbref[1:])
+
+ caller.msg(
+ "Puzzle |y'%s' |w%s(%s)|n has been created |gsuccessfully|n."
+ %(puzzle.db.puzzle_name,puzzle.name,puzzle.dbref)
+ )
+
+ caller.msg(
+ "You may now dispose of all parts and results. \n"
+ "Use @puzzleedit #{dbref} to customize this puzzle further. \n"
+ "Use @armpuzzle #{dbref} to arm a new puzzle instance.".format(dbref=puzzle.dbref)
+ )
+
+
+
[docs]classCmdEditPuzzle(MuxCommand):
+ """
+ Edits puzzle properties
+
+ Usage:
+ @puzzleedit[/delete] <#dbref>
+ @puzzleedit <#dbref>/use_success_message = <Custom message>
+ @puzzleedit <#dbref>/use_success_location_message = <Custom message from {caller} producing {result_names}>
+ @puzzleedit <#dbref>/mask = attr1[,attr2,...]>
+ @puzzleedit[/addpart] <#dbref> = <obj[,obj2,...]>
+ @puzzleedit[/delpart] <#dbref> = <obj[,obj2,...]>
+ @puzzleedit[/addresult] <#dbref> = <obj[,obj2,...]>
+ @puzzleedit[/delresult] <#dbref> = <obj[,obj2,...]>
+
+ Switches:
+ addpart - adds parts to the puzzle
+ delpart - removes parts from the puzzle
+ addresult - adds results to the puzzle
+ delresult - removes results from the puzzle
+ delete - deletes the recipe. Existing parts and results aren't modified
+
+ mask - attributes to exclude during matching (e.g. location, desc, etc.)
+ use_success_location_message containing {result_names} and {caller} will
+ automatically be replaced with correct values. Both are optional.
+
+ When removing parts/results, it's possible to remove all.
+
+ """
+
+ key="@puzzleedit"
+ locks="cmd:perm(puzzleedit) or perm(Builder)"
+ help_category="Puzzles"
+
+
[docs]classCmdArmPuzzle(MuxCommand):
+ """
+ Arms a puzzle by spawning all its parts.
+
+ Usage:
+ @armpuzzle <puzzle #dbref>
+
+ Notes:
+ Create puzzles with `@puzzle`; get list of
+ defined puzzles using `@lspuzzlerecipes`.
+
+ """
+
+ key="@armpuzzle"
+ locks="cmd:perm(armpuzzle) or perm(Builder)"
+ help_category="Puzzles"
+
+
+
+
+def_lookups_parts_puzzlenames_protodefs(parts):
+ # Create lookup dicts by part's dbref and by puzzle_name(tags)
+ parts_dict=dict()
+ puzzlename_tags_dict=dict()
+ puzzle_ingredients=dict()
+ forpartinparts:
+ parts_dict[part.dbref]=part
+ protodef=proto_def(part,with_tags=False)
+ # remove 'prototype_key' as it will prevent equality
+ delprotodef["prototype_key"]
+ puzzle_ingredients[part.dbref]=protodef
+ tags_categories=part.tags.all(return_key_and_category=True)
+ fortag,categoryintags_categories:
+ ifcategory!=_PUZZLES_TAG_CATEGORY:
+ continue
+ iftagnotinpuzzlename_tags_dict:
+ puzzlename_tags_dict[tag]=[]
+ puzzlename_tags_dict[tag].append(part.dbref)
+ returnparts_dict,puzzlename_tags_dict,puzzle_ingredients
+
+
+def_puzzles_by_names(names):
+ # Find all puzzles by puzzle name (i.e. tag name)
+ puzzles=[]
+ forpuzzle_nameinnames:
+ _puzzles=search.search_script_attribute(key="puzzle_name",value=puzzle_name)
+ _puzzles=list(filter(lambdap:isinstance(p,PuzzleRecipe),_puzzles))
+ ifnot_puzzles:
+ continue
+ else:
+ puzzles.extend(_puzzles)
+ returnpuzzles
+
+
+def_matching_puzzles(puzzles,puzzlename_tags_dict,puzzle_ingredients):
+ # Check if parts can be combined to solve a puzzle
+ matched_puzzles=dict()
+ forpuzzleinpuzzles:
+ puzzle_protoparts=list(puzzle.db.parts[:])
+ puzzle_mask=puzzle.db.mask[:]
+ # remove tags and prototype_key as they prevent equality
+ fori,puzzle_protopartinenumerate(puzzle_protoparts[:]):
+ delpuzzle_protopart["tags"]
+ delpuzzle_protopart["prototype_key"]
+ puzzle_protopart=maskout_protodef(puzzle_protopart,puzzle_mask)
+ puzzle_protoparts[i]=puzzle_protopart
+
+ matched_dbrefparts=[]
+ parts_dbrefs=puzzlename_tags_dict[puzzle.db.puzzle_name]
+ forpart_dbrefinparts_dbrefs:
+ protopart=puzzle_ingredients[part_dbref]
+ protopart=maskout_protodef(protopart,puzzle_mask)
+ ifprotopartinpuzzle_protoparts:
+ puzzle_protoparts.remove(protopart)
+ matched_dbrefparts.append(part_dbref)
+ else:
+ iflen(puzzle_protoparts)==0:
+ matched_puzzles[puzzle.dbref]=matched_dbrefparts
+ returnmatched_puzzles
+
+
+
[docs]classCmdUsePuzzleParts(MuxCommand):
+ """
+ Use an object, or a group of objects at once.
+
+
+ Example:
+ You look around you and see a pole, a long string, and a needle.
+
+ use pole, long string, needle
+
+ Genius! You built a fishing pole.
+
+
+ Usage:
+ use <obj1> [,obj2,...]
+ """
+
+ # Technical explanation
+ """
+ Searches for all puzzles whose parts match the given set of objects. If there are matching
+ puzzles, the result objects are spawned in their corresponding location if all parts have been
+ passed in.
+ """
+
+ key="use"
+ aliases="combine"
+ locks="cmd:pperm(use) or pperm(Player)"
+ help_category="Puzzles"
+
+
[docs]deffunc(self):
+ caller=self.caller
+
+ ifnotself.lhs:
+ caller.msg("Use what?")
+ return
+
+ many="these"iflen(self.lhslist)>1else"this"
+
+ # either all are parts, or abort finding matching puzzles
+ parts=[]
+ partnames=self.lhslist[:]
+ forpartnameinpartnames:
+ part=caller.search(
+ partname,
+ multimatch_string="Which %s. There are many.\n"%(partname),
+ nofound_string="There is no %s around."%(partname),
+ )
+
+ ifnotpart:
+ return
+
+ ifnotpart.tags.get(_PUZZLES_TAG_MEMBER,category=_PUZZLES_TAG_CATEGORY):
+
+ # not a puzzle part ... abort
+ caller.msg("You have no idea how %s can be used"%(many))
+ return
+
+ # a valid part
+ parts.append(part)
+
+ # Create lookup dicts by part's dbref and by puzzle_name(tags)
+ parts_dict,puzzlename_tags_dict,puzzle_ingredients=_lookups_parts_puzzlenames_protodefs(
+ parts
+ )
+
+ # Find all puzzles by puzzle name (i.e. tag name)
+ puzzles=_puzzles_by_names(puzzlename_tags_dict.keys())
+
+ logger.log_info("PUZZLES %r"%([(p.dbref,p.db.puzzle_name)forpinpuzzles]))
+
+ # Create lookup dict of puzzles by dbref
+ puzzles_dict=dict((puzzle.dbref,puzzle)forpuzzleinpuzzles)
+ # Check if parts can be combined to solve a puzzle
+ matched_puzzles=_matching_puzzles(puzzles,puzzlename_tags_dict,puzzle_ingredients)
+
+ iflen(matched_puzzles)==0:
+ # TODO: we could use part.fail_message instead, if there was one
+ # random part falls and lands on your feet
+ # random part hits you square on the face
+ caller.msg(_PUZZLE_DEFAULT_FAIL_USE_MESSAGE%(many))
+ return
+
+ puzzletuples=sorted(matched_puzzles.items(),key=lambdat:len(t[1]),reverse=True)
+
+ logger.log_info("MATCHED PUZZLES %r"%(puzzletuples))
+
+ # sort all matched puzzles and pick largest one(s)
+ puzzledbref,matched_dbrefparts=puzzletuples[0]
+ nparts=len(matched_dbrefparts)
+ puzzle=puzzles_dict[puzzledbref]
+ largest_puzzles=list(itertools.takewhile(lambdat:len(t[1])==nparts,puzzletuples))
+
+ # if there are more than one, choose one at random.
+ # we could show the names of all those that can be resolved
+ # but that would give away that there are other puzzles that
+ # can be resolved with the same parts.
+ # just hint how many.
+ iflen(largest_puzzles)>1:
+ caller.msg(
+ "Your gears start turning and %d different ideas come to your mind ...\n"
+ %(len(largest_puzzles))
+ )
+ puzzletuple=choice(largest_puzzles)
+ puzzle=puzzles_dict[puzzletuple[0]]
+ caller.msg("You try %s ..."%(puzzle.db.puzzle_name))
+
+ # got one, spawn its results
+ result_names=[]
+ forproto_resultinpuzzle.db.results:
+ result=spawn(proto_result)[0]
+ result.tags.add(puzzle.db.puzzle_name,category=_PUZZLES_TAG_CATEGORY)
+ result.db.puzzle_name=puzzle.db.puzzle_name
+ result_names.append(result.name)
+
+ # Destroy all parts used
+ fordbrefinmatched_dbrefparts:
+ parts_dict[dbref].delete()
+
+ result_names=", ".join(result_names)
+ caller.msg(puzzle.db.use_success_message)
+ caller.location.msg_contents(
+ puzzle.db.use_success_location_message.format(caller=caller,result_names=result_names),
+ exclude=(caller,),
+ )
+
+
+
[docs]classCmdListPuzzleRecipes(MuxCommand):
+ """
+ Searches for all puzzle recipes
+
+ Usage:
+ @lspuzzlerecipes
+ """
+
+ key="@lspuzzlerecipes"
+ locks="cmd:perm(lspuzzlerecipes) or perm(Builder)"
+ help_category="Puzzles"
+
+
Source code for evennia.contrib.random_string_generator
+"""
+Pseudo-random generator and registry
+
+Evennia contribution - Vincent Le Goff 2017
+
+This contrib can be used to generate pseudo-random strings of information
+with specific criteria. You could, for instance, use it to generate
+phone numbers, license plate numbers, validation codes, non-sensivite
+passwords and so on. The strings generated by the generator will be
+stored and won't be available again in order to avoid repetition.
+Here's a very simple example:
+
+```python
+from evennia.contrib.random_string_generator import RandomStringGenerator
+# Create a generator for phone numbers
+phone_generator = RandomStringGenerator("phone number", r"555-[0-9]{3}-[0-9]{4}")
+# Generate a phone number (555-XXX-XXXX with X as numbers)
+number = phone_generator.get()
+# `number` will contain something like: "555-981-2207"
+# If you call `phone_generator.get`, it won't give the same anymore.phone_generator.all()
+# Will return a list of all currently-used phone numbers
+phone_generator.remove("555-981-2207")
+# The number can be generated again
+```
+
+To use it, you will need to:
+
+1. Import the `RandomStringGenerator` class from the contrib.
+2. Create an instance of this class taking two arguments:
+ - The name of the gemerator (like "phone number", "license plate"...).
+ - The regular expression representing the expected results.
+3. Use the generator's `all`, `get` and `remove` methods as shown above.
+
+To understand how to read and create regular expressions, you can refer to
+[the documentation on the re module](https://docs.python.org/2/library/re.html).
+Some examples of regular expressions you could use:
+
+- `r"555-\d{3}-\d{4}"`: 555, a dash, 3 digits, another dash, 4 digits.
+- `r"[0-9]{3}[A-Z][0-9]{3}"`: 3 digits, a capital letter, 3 digits.
+- `r"[A-Za-z0-9]{8,15}"`: between 8 and 15 letters and digits.
+- ...
+
+Behind the scenes, a script is created to store the generated information
+for a single generator. The `RandomStringGenerator` object will also
+read the regular expression you give to it to see what information is
+required (letters, digits, a more restricted class, simple characters...)...
+More complex regular expressions (with branches for instance) might not be
+available.
+
+"""
+
+fromrandomimportchoice,randint,seed
+importre
+importstring
+importtime
+
+fromevenniaimportDefaultScript,ScriptDB
+fromevennia.utils.createimportcreate_script
+
+
+
[docs]classRejectedRegex(RuntimeError):
+
+ """The provided regular expression has been rejected.
+
+ More details regarding why this error occurred will be provided in
+ the message. The usual reason is the provided regular expression is
+ not specific enough and could lead to inconsistent generating.
+
+ """
+
+ pass
+
+
+
[docs]classExhaustedGenerator(RuntimeError):
+
+ """The generator hasn't any available strings to generate anymore."""
+
+ pass
+
+
+
[docs]classRandomStringGeneratorScript(DefaultScript):
+
+ """
+ The global script to hold all generators.
+
+ It will be automatically created the first time `generate` is called
+ on a RandomStringGenerator object.
+
+ """
+
+
[docs]defat_script_creation(self):
+ """Hook called when the script is created."""
+ self.key="generator_script"
+ self.desc="Global generator script"
+ self.persistent=True
+
+ # Permanent data to be stored
+ self.db.generated={}
+
+
+
[docs]classRandomStringGenerator(object):
+
+ """
+ A generator class to generate pseudo-random strings with a rule.
+
+ The "rule" defining what the generator should provide in terms of
+ string is given as a regular expression when creating instances of
+ this class. You can use the `all` method to get all generated strings,
+ the `get` method to generate a new string, the `remove` method
+ to remove a generated string, or the `clear` method to remove all
+ generated strings.
+
+ Bear in mind, however, that while the generated strings will be
+ stored to avoid repetition, the generator will not concern itself
+ with how the string is stored on the object you use. You probably
+ want to create a tag to mark this object. This is outside of the scope
+ of this class.
+
+ """
+
+ # We keep the script as a class variable to optimize querying
+ # with multiple instandces
+ script=None
+
+
[docs]def__init__(self,name,regex):
+ """
+ Create a new generator.
+
+ Args:
+ name (str): name of the generator to create.
+ regex (str): regular expression describing the generator.
+
+ Notes:
+ `name` should be an explicit name. If you use more than one
+ generator in your game, be sure to give them different names.
+ This name will be used to store the generated information
+ in the global script, and in case of errors.
+
+ The regular expression should describe the generator, what
+ it should generate: a phone number, a license plate, a password
+ or something else. Regular expressions allow you to use
+ pretty advanced criteria, but be aware that some regular
+ expressions will be rejected if not specific enough.
+
+ Raises:
+ RejectedRegex: the provided regular expression couldn't be
+ accepted as a valid generator description.
+
+ """
+ self.name=name
+ self.elements=[]
+ self.total=1
+
+ # Analyze the regex if any
+ ifregex:
+ self._find_elements(regex)
+
+ def__repr__(self):
+ return"<evennia.contrib.random_string_generator.RandomStringGenerator for {}>".format(
+ self.name
+ )
+
+ def_get_script(self):
+ """Get or create the script."""
+ iftype(self).script:
+ returntype(self).script
+
+ try:
+ script=ScriptDB.objects.get(db_key="generator_script")
+ exceptScriptDB.DoesNotExist:
+ script=create_script("contrib.random_string_generator.RandomStringGeneratorScript")
+
+ type(self).script=script
+ returnscript
+
+ def_find_elements(self,regex):
+ """
+ Find the elements described in the regular expression. This will
+ analyze the provided regular expression and try to find elements.
+
+ Args:
+ regex (str): the regular expression.
+
+ """
+ self.total=1
+ self.elements=[]
+ tree=re.sre_parse.parse(regex).data
+ # `tree` contains a list of elements in the regular expression
+ forelementintree:
+ # `element` is also a list, the first element is a string
+ name=str(element[0]).lower()
+ desc={"min":1,"max":1}
+
+ # If `.`, break here
+ ifname=="any":
+ raiseRejectedRegex(
+ "the . definition is too broad, specify what you need more precisely"
+ )
+ elifname=="at":
+ # Either the beginning or end, we ignore it
+ continue
+ elifname=="min_repeat":
+ raiseRejectedRegex("you have to provide a maximum number of this character class")
+ elifname=="max_repeat":
+ desc["min"]=element[1][0]
+ desc["max"]=element[1][1]
+ desc["chars"]=self._find_literal(element[1][2][0])
+ elifname=="in":
+ desc["chars"]=self._find_literal(element)
+ elifname=="literal":
+ desc["chars"]=self._find_literal(element)
+ else:
+ raiseRejectedRegex("unhandled regex syntax:: {}".format(repr(name)))
+
+ self.elements.append(desc)
+ self.total*=len(desc["chars"])**desc["max"]
+
+ def_find_literal(self,element):
+ """Find the literal corresponding to a piece of regular expression."""
+ name=str(element[0]).lower()
+ chars=[]
+ ifname=="literal":
+ chars.append(chr(element[1]))
+ elifname=="in":
+ negate=False
+ ifelement[1][0][0]=="negate":
+ negate=True
+ chars=list(string.ascii_letters+string.digits)
+
+ forpartinelement[1]:
+ ifpart[0]=="negate":
+ continue
+
+ sublist=self._find_literal(part)
+ forcharinsublist:
+ ifnegate:
+ ifcharinchars:
+ chars.remove(char)
+ else:
+ chars.append(char)
+ elifname=="range":
+ chars=[chr(i)foriinrange(element[1][0],element[1][1]+1)]
+ elifname=="category":
+ category=str(element[1]).lower()
+ ifcategory=="category_digit":
+ chars=list(string.digits)
+ elifcategory=="category_word":
+ chars=list(string.letters)
+ else:
+ raiseRejectedRegex("unknown category: {}".format(category))
+ else:
+ raiseRejectedRegex("cannot find the literal: {}".format(element[0]))
+
+ returnchars
+
+
[docs]defall(self):
+ """
+ Return all generated strings for this generator.
+
+ Returns:
+ strings (list of strr): the list of strings that are already
+ used. The strings that were generated first come first in the list.
+
+ """
+ script=self._get_script()
+ generated=list(script.db.generated.get(self.name,[]))
+ returngenerated
+
+
[docs]defget(self,store=True,unique=True):
+ """
+ Generate a pseudo-random string according to the regular expression.
+
+ Args:
+ store (bool, optional): store the generated string in the script.
+ unique (bool, optional): keep on trying if the string is already used.
+
+ Returns:
+ The newly-generated string.
+
+ Raises:
+ ExhaustedGenerator: if there's no available string in this generator.
+
+ Note:
+ Unless asked explicitly, the returned string can't repeat itself.
+
+ """
+ script=self._get_script()
+ generated=script.db.generated.get(self.name)
+ ifgeneratedisNone:
+ script.db.generated[self.name]=[]
+ generated=script.db.generated[self.name]
+
+ iflen(generated)>=self.total:
+ raiseExhaustedGenerator
+
+ # Generate a pseudo-random string that might be used already
+ result=""
+ forelementinself.elements:
+ number=randint(element["min"],element["max"])
+ chars=element["chars"]
+ forindexinrange(number):
+ char=choice(chars)
+ result+=char
+
+ # If the string has already been generated, try again
+ ifresultingeneratedandunique:
+ # Change the random seed, incrementing it slowly
+ epoch=time.time()
+ whileresultingenerated:
+ epoch+=1
+ seed(epoch)
+ result=self.get(store=False,unique=False)
+
+ ifstore:
+ generated.append(result)
+
+ returnresult
+
+
[docs]defremove(self,element):
+ """
+ Remove a generated string from the list of stored strings.
+
+ Args:
+ element (str): the string to remove from the list of generated strings.
+
+ Raises:
+ ValueError: the specified value hasn't been generated and is not present.
+
+ Note:
+ The specified string has to be present in the script (so
+ has to have been generated). It will remove this entry
+ from the script, so this string could be generated again by
+ calling the `get` method.
+
+ """
+ script=self._get_script()
+ generated=script.db.generated.get(self.name,[])
+ ifelementnotingenerated:
+ raiseValueError(
+ "the string {} isn't stored as generated by the generator {}".format(
+ element,self.name
+ )
+ )
+
+ generated.remove(element)
+
+
[docs]defclear(self):
+ """
+ Clear the generator of all generated strings.
+
+ """
+ script=self._get_script()
+ generated=script.db.generated.get(self.name,[])
+ generated[:]=[]
+"""
+Language and whisper obfuscation system
+
+Evennia contrib - Griatch 2015
+
+
+This module is intented to be used with an emoting system (such as
+contrib/rpsystem.py). It offers the ability to obfuscate spoken words
+in the game in various ways:
+
+- Language: The language functionality defines a pseudo-language map
+ to any number of languages. The string will be obfuscated depending
+ on a scaling that (most likely) will be input as a weighted average of
+ the language skill of the speaker and listener.
+- Whisper: The whisper functionality will gradually "fade out" a
+ whisper along as scale 0-1, where the fading is based on gradually
+ removing sections of the whisper that is (supposedly) easier to
+ overhear (for example "s" sounds tend to be audible even when no other
+ meaning can be determined).
+
+Usage:
+
+ ```python
+ from evennia.contrib import rplanguage
+
+ # need to be done once, here we create the "default" lang
+ rplanguage.add_language()
+
+ say = "This is me talking."
+ whisper = "This is me whispering.
+
+ print rplanguage.obfuscate_language(say, level=0.0)
+ <<< "This is me talking."
+ print rplanguage.obfuscate_language(say, level=0.5)
+ <<< "This is me byngyry."
+ print rplanguage.obfuscate_language(say, level=1.0)
+ <<< "Daly ly sy byngyry."
+
+ result = rplanguage.obfuscate_whisper(whisper, level=0.0)
+ <<< "This is me whispering"
+ result = rplanguage.obfuscate_whisper(whisper, level=0.2)
+ <<< "This is m- whisp-ring"
+ result = rplanguage.obfuscate_whisper(whisper, level=0.5)
+ <<< "---s -s -- ---s------"
+ result = rplanguage.obfuscate_whisper(whisper, level=0.7)
+ <<< "---- -- -- ----------"
+ result = rplanguage.obfuscate_whisper(whisper, level=1.0)
+ <<< "..."
+
+ ```
+
+ To set up new languages, import and use the `add_language()`
+ helper method in this module. This allows you to customize the
+ "feel" of the semi-random language you are creating. Especially
+ the `word_length_variance` helps vary the length of translated
+ words compared to the original and can help change the "feel" for
+ the language you are creating. You can also add your own
+ dictionary and "fix" random words for a list of input words.
+
+ Below is an example of "elvish", using "rounder" vowels and sounds:
+
+ ```python
+ phonemes = "oi oh ee ae aa eh ah ao aw ay er ey ow ia ih iy " \
+ "oy ua uh uw y p b t d f v t dh s z sh zh ch jh k " \
+ "ng g m n l r w",
+ vowels = "eaoiuy"
+ grammar = "v vv vvc vcc vvcc cvvc vccv vvccv vcvccv vcvcvcc vvccvvcc " \
+ "vcvvccvvc cvcvvcvvcc vcvcvvccvcvv",
+ word_length_variance = 1
+ noun_postfix = "'la"
+ manual_translations = {"the":"y'e", "we":"uyi", "she":"semi", "he":"emi",
+ "you": "do", 'me':'mi','i':'me', 'be':"hy'e", 'and':'y'}
+
+ rplanguage.add_language(key="elvish", phonemes=phonemes, grammar=grammar,
+ word_length_variance=word_length_variance,
+ noun_postfix=noun_postfix, vowels=vowels,
+ manual_translations=manual_translations
+ auto_translations="my_word_file.txt")
+
+ ```
+
+ This will produce a decicively more "rounded" and "soft" language
+ than the default one. The few manual_translations also make sure
+ to make it at least look superficially "reasonable".
+
+ The `auto_translations` keyword is useful, this accepts either a
+ list or a path to a file of words (one per line) to automatically
+ create fixed translations for according to the grammatical rules.
+ This allows to quickly build a large corpus of translated words
+ that never change (if this is desired).
+
+"""
+importre
+fromrandomimportchoice,randint
+fromcollectionsimportdefaultdict
+fromevenniaimportDefaultScript
+fromevennia.utilsimportlogger
+
+
+# ------------------------------------------------------------
+#
+# Obfuscate language
+#
+# ------------------------------------------------------------
+
+# default language grammar
+_PHONEMES=(
+ "ea oh ae aa eh ah ao aw ai er ey ow ia ih iy oy ua uh uw a e i u y p b t d f v t dh "
+ "s z sh zh ch jh k ng g m n l r w"
+)
+_VOWELS="eaoiuy"
+# these must be able to be constructed from phonemes (so for example,
+# if you have v here, there must exist at least one single-character
+# vowel phoneme defined above)
+_GRAMMAR="v cv vc cvv vcc vcv cvcc vccv cvccv cvcvcc cvccvcv vccvccvc cvcvccvv cvcvcvcvv"
+
+_RE_FLAGS=re.MULTILINE+re.IGNORECASE+re.DOTALL+re.UNICODE
+_RE_GRAMMAR=re.compile(r"vv|cc|v|c",_RE_FLAGS)
+_RE_WORD=re.compile(r"\w+",_RE_FLAGS)
+_RE_EXTRA_CHARS=re.compile(r"\s+(?=\W)|[,.?;](?=[,.?;]|\s+[,.?;])",_RE_FLAGS)
+
+
+
[docs]classLanguageHandler(DefaultScript):
+ """
+ This is a storage class that should usually not be created on its
+ own. It's automatically created by a call to `obfuscate_language`
+ or `add_language` below.
+
+ Languages are implemented as a "logical" pseudo- consistent language
+ algorith here. The idea is that a language is built up from
+ phonemes. These are joined together according to a "grammar" of
+ possible phoneme- combinations and allowed characters. It may
+ sound simplistic, but this allows to easily make
+ "similar-sounding" languages. One can also custom-define a
+ dictionary of some common words to give further consistency.
+ Optionally, the system also allows an input list of common words
+ to be loaded and given random translations. These will be stored
+ to disk and will thus not change. This gives a decent "stability"
+ of the language but if the goal is to obfuscate, this may allow
+ players to eventually learn to understand the gist of a sentence
+ even if their characters can not. Any number of languages can be
+ created this way.
+
+ This nonsense language will partially replace the actual spoken
+ language when so desired (usually because the speaker/listener
+ don't know the language well enough).
+
+ """
+
+
[docs]defat_script_creation(self):
+ "Called when script is first started"
+ self.key="language_handler"
+ self.persistent=True
+ self.db.language_storage={}
+
+
[docs]defadd(
+ self,
+ key="default",
+ phonemes=_PHONEMES,
+ grammar=_GRAMMAR,
+ word_length_variance=0,
+ noun_translate=False,
+ noun_prefix="",
+ noun_postfix="",
+ vowels=_VOWELS,
+ manual_translations=None,
+ auto_translations=None,
+ force=False,
+ ):
+ """
+ Add a new language. Note that you generally only need to do
+ this once per language and that adding an existing language
+ will re-initialize all the random components to new permanent
+ values.
+
+ Args:
+ key (str, optional): The name of the language. This
+ will be used as an identifier for the language so it
+ should be short and unique.
+ phonemes (str, optional): Space-separated string of all allowed
+ phonemes in this language. If either of the base phonemes
+ (c, v, cc, vv) are present in the grammar, the phoneme list must
+ at least include one example of each.
+ grammar (str): All allowed consonant (c) and vowel (v) combinations
+ allowed to build up words. Grammars are broken into the base phonemes
+ (c, v, cc, vv) prioritizing the longer bases. So cvv would be a
+ the c + vv (would allow for a word like 'die' whereas
+ cvcvccc would be c+v+c+v+cc+c (a word like 'galosch').
+ word_length_variance (real): The variation of length of words.
+ 0 means a minimal variance, higher variance may mean words
+ have wildly varying length; this strongly affects how the
+ language "looks".
+ noun_translate (bool, optional): If a proper noun, identified as a
+ capitalized word, should be translated or not. By default they
+ will not, allowing for e.g. the names of characters to be understandable.
+ noun_prefix (str, optional): A prefix to go before every noun
+ in this language (if any).
+ noun_postfix (str, optuonal): A postfix to go after every noun
+ in this language (if any, usually best to avoid combining
+ with `noun_prefix` or language becomes very wordy).
+ vowels (str, optional): Every vowel allowed in this language.
+ manual_translations (dict, optional): This allows for custom-setting
+ certain words in the language to mean the same thing. It is
+ on the form `{real_word: fictional_word}`, for example
+ `{"the", "y'e"}` .
+ auto_translations (str or list, optional): These are lists
+ words that should be auto-translated with a random, but
+ fixed, translation. If a path to a file, this file should
+ contain a list of words to produce translations for, one
+ word per line. If a list, the list's elements should be
+ the words to translate. The `manual_translations` will
+ always override overlapping translations created
+ automatically.
+ force (bool, optional): Unless true, will not allow the addition
+ of a language that is already created.
+
+ Raises:
+ LanguageExistsError: Raised if trying to adding a language
+ with a key that already exists, without `force` being set.
+ Notes:
+ The `word_file` is for example a word-frequency list for
+ the N most common words in the host language. The
+ translations will be random, but will be stored
+ persistently to always be the same. This allows for
+ building a quick, decently-sounding fictive language that
+ tend to produce the same "translation" (mostly) with the
+ same input sentence.
+
+ """
+ ifkeyinself.db.language_storageandnotforce:
+ raiseLanguageExistsError(
+ "Language is already created. Re-adding it will re-build"
+ " its dictionary map. Use 'force=True' keyword if you are sure."
+ )
+
+ # create grammar_component->phoneme mapping
+ # {"vv": ["ea", "oh", ...], ...}
+ grammar2phonemes=defaultdict(list)
+ forphonemeinphonemes.split():
+ ifre.search("\W",phoneme):
+ raiseLanguageError("The phoneme '%s' contains an invalid character"%phoneme)
+ gram="".join(["v"ifcharinvowelselse"c"forcharinphoneme])
+ grammar2phonemes[gram].append(phoneme)
+
+ # allowed grammar are grouped by length
+ gramdict=defaultdict(list)
+ forgramingrammar.split():
+ ifre.search("\W|(!=[cv])",gram):
+ raiseLanguageError(
+ "The grammar '%s' is invalid (only 'c' and 'v' are allowed)"%gram
+ )
+ gramdict[len(gram)].append(gram)
+ grammar=dict(gramdict)
+
+ # create automatic translation
+ translation={}
+
+ ifauto_translations:
+ ifisinstance(auto_translations,str):
+ # path to a file rather than a list
+ withopen(auto_translations,"r")asf:
+ auto_translations=f.readlines()
+ forwordinauto_translations:
+ word=word.strip()
+ lword=len(word)
+ new_word=""
+ wlen=max(0,lword+sum(randint(-1,1)foriinrange(word_length_variance)))
+ ifwlennotingrammar:
+ # always create a translation, use random length
+ structure=choice(grammar[choice(list(grammar))])
+ else:
+ # use the corresponding length
+ structure=choice(grammar[wlen])
+ formatchin_RE_GRAMMAR.finditer(structure):
+ new_word+=choice(grammar2phonemes[match.group()])
+ translation[word.lower()]=new_word.lower()
+
+ ifmanual_translations:
+ # update with manual translations
+ translation.update(
+ dict((key.lower(),value.lower())forkey,valueinmanual_translations.items())
+ )
+
+ # store data
+ storage={
+ "translation":translation,
+ "grammar":grammar,
+ "grammar2phonemes":dict(grammar2phonemes),
+ "word_length_variance":word_length_variance,
+ "noun_translate":noun_translate,
+ "noun_prefix":noun_prefix,
+ "noun_postfix":noun_postfix,
+ }
+ self.db.language_storage[key]=storage
+
+ def_translate_sub(self,match):
+ """
+ Replacer method called by re.sub when
+ traversing the language string.
+
+ Args:
+ match (re.matchobj): Match object from regex.
+
+ Returns:
+ converted word.
+ Notes:
+ Assumes self.lastword and self.level is available
+ on the object.
+
+ """
+ word=match.group()
+ lword=len(word)
+
+ iflen(word)<=self.level:
+ # below level. Don't translate
+ new_word=word
+ else:
+ # try to translate the word from dictionary
+ new_word=self.language["translation"].get(word.lower(),"")
+ ifnotnew_word:
+ # no dictionary translation. Generate one
+
+ # find out what preceeded this word
+ wpos=match.start()
+ preceeding=match.string[:wpos].strip()
+ start_sentence=preceeding.endswith((".","!","?"))ornotpreceeding
+
+ # make up translation on the fly. Length can
+ # vary from un-translated word.
+ wlen=max(
+ 0,
+ lword
+ +sum(randint(-1,1)foriinrange(self.language["word_length_variance"])),
+ )
+ grammar=self.language["grammar"]
+ ifwlennotingrammar:
+ ifrandint(0,1)==0:
+ # this word has no direct translation!
+ wlen=0
+ new_word=""
+ else:
+ # use random word length
+ wlen=choice(list(grammar.keys()))
+
+ ifwlen:
+ structure=choice(grammar[wlen])
+ grammar2phonemes=self.language["grammar2phonemes"]
+ formatchin_RE_GRAMMAR.finditer(structure):
+ # there are only four combinations: vv,cc,c,v
+ try:
+ new_word+=choice(grammar2phonemes[match.group()])
+ exceptKeyError:
+ logger.log_trace(
+ "You need to supply at least one example of each of "
+ "the four base phonemes (c, v, cc, vv)"
+ )
+ # abort translation here
+ new_word=""
+ break
+
+ ifword.istitle():
+ title_word=""
+ ifnotstart_sentenceandnotself.language.get("noun_translate",False):
+ # don't translate what we identify as proper nouns (names)
+ title_word=word
+ elifnew_word:
+ title_word=new_word
+
+ iftitle_word:
+ # Regardless of if we translate or not, we will add the custom prefix/postfixes
+ new_word="%s%s%s"%(
+ self.language["noun_prefix"],
+ title_word.capitalize(),
+ self.language["noun_postfix"],
+ )
+
+ iflen(word)>1andword.isupper():
+ # keep LOUD words loud also when translated
+ new_word=new_word.upper()
+ returnnew_word
+
+
[docs]deftranslate(self,text,level=0.0,language="default"):
+ """
+ Translate the text according to the given level.
+
+ Args:
+ text (str): The text to translate
+ level (real): Value between 0.0 and 1.0, where
+ 0.0 means no obfuscation (text returned unchanged) and
+ 1.0 means full conversion of every word. The closer to
+ 1, the shorter words will be translated.
+ language (str): The language key identifier.
+
+ Returns:
+ text (str): A translated string.
+
+ """
+ iflevel==0.0:
+ # no translation
+ returntext
+ language=self.db.language_storage.get(language,None)
+ ifnotlanguage:
+ returntext
+ self.language=language
+
+ # configuring the translation
+ self.level=int(10*(1.0-max(0,min(level,1.0))))
+ translation=_RE_WORD.sub(self._translate_sub,text)
+ # the substitution may create too long empty spaces, remove those
+ return_RE_EXTRA_CHARS.sub("",translation)
[docs]defobfuscate_language(text,level=0.0,language="default"):
+ """
+ Main access method for the language parser.
+
+ Args:
+ text (str): Text to obfuscate.
+ level (real, optional): A value from 0.0-1.0 determining
+ the level of obfuscation where 0 means no jobfuscation
+ (string returned unchanged) and 1.0 means the entire
+ string is obfuscated.
+ language (str, optional): The identifier of a language
+ the system understands.
+
+ Returns:
+ translated (str): The translated text.
+
+ """
+ # initialize the language handler and cache it
+ global_LANGUAGE_HANDLER
+ ifnot_LANGUAGE_HANDLER:
+ try:
+ _LANGUAGE_HANDLER=LanguageHandler.objects.get(db_key="language_handler")
+ exceptLanguageHandler.DoesNotExist:
+ ifnot_LANGUAGE_HANDLER:
+ fromevenniaimportcreate_script
+
+ _LANGUAGE_HANDLER=create_script(LanguageHandler)
+ return_LANGUAGE_HANDLER.translate(text,level=level,language=language)
+
+
+
[docs]defadd_language(**kwargs):
+ """
+ Access function to creating a new language. See the docstring of
+ `LanguageHandler.add` for list of keyword arguments.
+
+ """
+ global_LANGUAGE_HANDLER
+ ifnot_LANGUAGE_HANDLER:
+ try:
+ _LANGUAGE_HANDLER=LanguageHandler.objects.get(db_key="language_handler")
+ exceptLanguageHandler.DoesNotExist:
+ ifnot_LANGUAGE_HANDLER:
+ fromevenniaimportcreate_script
+
+ _LANGUAGE_HANDLER=create_script(LanguageHandler)
+ _LANGUAGE_HANDLER.add(**kwargs)
+
+
+
[docs]defavailable_languages():
+ """
+ Returns all available language keys.
+
+ Returns:
+ languages (list): List of key strings of all available
+ languages.
+ """
+ global_LANGUAGE_HANDLER
+ ifnot_LANGUAGE_HANDLER:
+ try:
+ _LANGUAGE_HANDLER=LanguageHandler.objects.get(db_key="language_handler")
+ exceptLanguageHandler.DoesNotExist:
+ ifnot_LANGUAGE_HANDLER:
+ fromevenniaimportcreate_script
+
+ _LANGUAGE_HANDLER=create_script(LanguageHandler)
+ returnlist(_LANGUAGE_HANDLER.attributes.get("language_storage",{}))
+
+
+# ------------------------------------------------------------
+#
+# Whisper obscuration
+#
+# This obsucration table is designed by obscuring certain
+# vowels first, following by consonants that tend to be
+# more audible over long distances, like s. Finally it
+# does non-auditory replacements, like exclamation marks
+# and capitalized letters (assumed to be spoken louder) that may still
+# give a user some idea of the sentence structure. Then the word
+# lengths are also obfuscated and finally the whisper # length itself.
+#
+# ------------------------------------------------------------
+
+
+_RE_WHISPER_OBSCURE=[
+ re.compile(r"^$",_RE_FLAGS),# This is a Test! #0 full whisper
+ re.compile(r"[ae]",_RE_FLAGS),# This -s - Test! #1 add uy
+ re.compile(r"[aeuy]",_RE_FLAGS),# This -s - Test! #2 add oue
+ re.compile(r"[aeiouy]",_RE_FLAGS),# Th-s -s - T-st! #3 add all consonants
+ re.compile(r"[aeiouybdhjlmnpqrv]",_RE_FLAGS),# T--s -s - T-st! #4 add hard consonants
+ re.compile(r"[a-eg-rt-z]",_RE_FLAGS),# T--s -s - T-s-! #5 add all capitals
+ re.compile(r"[A-EG-RT-Za-eg-rt-z]",_RE_FLAGS),# ---s -s - --s-! #6 add f
+ re.compile(r"[A-EG-RT-Za-rt-z]",_RE_FLAGS),# ---s -s - --s-! #7 add s
+ re.compile(r"[A-EG-RT-Za-z]",_RE_FLAGS),# ---- -- - ----! #8 add capital F
+ re.compile(r"[A-RT-Za-z]",_RE_FLAGS),# ---- -- - ----! #9 add capital S
+ re.compile(r"[\w]",_RE_FLAGS),# ---- -- - ----! #10 non-alphanumerals
+ re.compile(r"[\S]",_RE_FLAGS),# ---- -- - ---- #11 words
+ re.compile(r"[\w\W]",_RE_FLAGS),# -------------- #12 whisper length
+ re.compile(r".*",_RE_FLAGS),
+]# ... #13 (always same length)
+
+
+
[docs]defobfuscate_whisper(whisper,level=0.0):
+ """
+ Obfuscate whisper depending on a pre-calculated level
+ (that may depend on distance, listening skill etc)
+
+ Args:
+ whisper (str): The whisper string to obscure. The
+ entire string will be considered in the obscuration.
+ level (real, optional): This is a value 0-1, where 0
+ means not obscured (whisper returned unchanged) and 1
+ means fully obscured.
+
+ """
+ level=min(max(0.0,level),1.0)
+ olevel=int(13.0*level)
+ ifolevel==13:
+ return"..."
+ else:
+ return_RE_WHISPER_OBSCURE[olevel].sub("-",whisper)
+"""
+Roleplaying base system for Evennia
+
+Contribution - Griatch, 2015
+
+This module contains the ContribRPObject, ContribRPRoom and
+ContribRPCharacter typeclasses. If you inherit your
+objects/rooms/character from these (or make them the defaults) from
+these you will get the following features:
+
+ - Objects/Rooms will get the ability to have poses and will report
+ the poses of items inside them (the latter most useful for Rooms).
+ - Characters will get poses and also sdescs (short descriptions)
+ that will be used instead of their keys. They will gain commands
+ for managing recognition (custom sdesc-replacement), masking
+ themselves as well as an advanced free-form emote command.
+
+To use, simply import the typclasses you want from this module and use
+them to create your objects, or set them to default.
+
+In more detail, This RP base system introduces the following features
+to a game, common to many RP-centric games:
+
+ - emote system using director stance emoting (names/sdescs).
+ This uses a customizable replacement noun (/me, @ etc) to
+ represent you in the emote. You can use /sdesc, /nick, /key or
+ /alias to reference objects in the room. You can use any
+ number of sdesc sub-parts to differentiate a local sdesc, or
+ use /1-sdesc etc to differentiate them. The emote also
+ identifies nested says.
+ - sdesc obscuration of real character names for use in emotes
+ and in any referencing such as object.search(). This relies
+ on an SdescHandler `sdesc` being set on the Character and
+ makes use of a custom Character.get_display_name hook. If
+ sdesc is not set, the character's `key` is used instead. This
+ is particularly used in the emoting system.
+ - recog system to assign your own nicknames to characters, can then
+ be used for referencing. The user may recog a user and assign
+ any personal nick to them. This will be shown in descriptions
+ and used to reference them. This is making use of the nick
+ functionality of Evennia.
+ - masks to hide your identity (using a simple lock).
+ - pose system to set room-persistent poses, visible in room
+ descriptions and when looking at the person/object. This is a
+ simple Attribute that modifies how the characters is viewed when
+ in a room as sdesc + pose.
+ - in-emote says, including seamless integration with language
+ obscuration routine (such as contrib/rplanguage.py)
+
+Examples:
+
+> look
+Tavern
+The tavern is full of nice people
+
+*A tall man* is standing by the bar.
+
+Above is an example of a player with an sdesc "a tall man". It is also
+an example of a static *pose*: The "standing by the bar" has been set
+by the player of the tall man, so that people looking at him can tell
+at a glance what is going on.
+
+> emote /me looks at /tall and says "Hello!"
+
+I see:
+ Griatch looks at Tall man and says "Hello".
+Tall man (assuming his name is Tom) sees:
+ The godlike figure looks at Tom and says "Hello".
+
+Verbose Installation Instructions:
+
+ 1. In typeclasses/character.py:
+ Import the `ContribRPCharacter` class:
+ `from evennia.contrib.rpsystem import ContribRPCharacter`
+ Inherit ContribRPCharacter:
+ Change "class Character(DefaultCharacter):" to
+ `class Character(ContribRPCharacter):`
+ If you have any overriden calls in `at_object_creation(self)`:
+ Add `super().at_object_creation()` as the top line.
+ 2. In `typeclasses/rooms.py`:
+ Import the `ContribRPRoom` class:
+ `from evennia.contrib.rpsystem import ContribRPRoom`
+ Inherit `ContribRPRoom`:
+ Change `class Room(DefaultRoom):` to
+ `class Room(ContribRPRoom):`
+ 3. In `typeclasses/objects.py`
+ Import the `ContribRPObject` class:
+ `from evennia.contrib.rpsystem import ContribRPObject`
+ Inherit `ContribRPObject`:
+ Change `class Object(DefaultObject):` to
+ `class Object(ContribRPObject):`
+ 4. Reload the server (@reload or from console: "evennia reload")
+ 5. Force typeclass updates as required. Example for your character:
+ @type/reset/force me = typeclasses.characters.Character
+
+"""
+importre
+fromreimportescapeasre_escape
+importitertools
+fromdjango.confimportsettings
+fromevenniaimportDefaultObject,DefaultCharacter,ObjectDB
+fromevenniaimportCommand,CmdSet
+fromevenniaimportansi
+fromevennia.utils.utilsimportlazy_property,make_iter,variable_from_module
+
+_AT_SEARCH_RESULT=variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".",1))
+# ------------------------------------------------------------
+# Emote parser
+# ------------------------------------------------------------
+
+# Settings
+
+# The prefix is the (single-character) symbol used to find the start
+# of a object reference, such as /tall (note that
+# the system will understand multi-word references like '/a tall man' too).
+_PREFIX="/"
+
+# The num_sep is the (single-character) symbol used to separate the
+# sdesc from the number when trying to separate identical sdescs from
+# one another. This is the same syntax used in the rest of Evennia, so
+# by default, multiple "tall" can be separated by entering 1-tall,
+# 2-tall etc.
+_NUM_SEP="-"
+
+# Texts
+
+_EMOTE_NOMATCH_ERROR="""|RNo match for |r{ref}|R.|n"""
+
+_EMOTE_MULTIMATCH_ERROR="""|RMultiple possibilities for {ref}:
+ |r{reflist}|n"""
+
+_RE_FLAGS=re.MULTILINE+re.IGNORECASE+re.UNICODE
+
+_RE_PREFIX=re.compile(r"^%s"%_PREFIX,re.UNICODE)
+
+# This regex will return groups (num, word), where num is an optional counter to
+# separate multimatches from one another and word is the first word in the
+# marker. So entering "/tall man" will return groups ("", "tall")
+# and "/2-tall man" will return groups ("2", "tall").
+_RE_OBJ_REF_START=re.compile(r"%s(?:([0-9]+)%s)*(\w+)"%(_PREFIX,_NUM_SEP),_RE_FLAGS)
+
+_RE_LEFT_BRACKETS=re.compile(r"\{+",_RE_FLAGS)
+_RE_RIGHT_BRACKETS=re.compile(r"\}+",_RE_FLAGS)
+# Reference markers are used internally when distributing the emote to
+# all that can see it. They are never seen by players and are on the form {#dbref}.
+_RE_REF=re.compile(r"\{+\#([0-9]+)\}+")
+
+# This regex is used to quickly reference one self in an emote.
+_RE_SELF_REF=re.compile(r"/me|@",_RE_FLAGS)
+
+# regex for non-alphanumberic end of a string
+_RE_CHAREND=re.compile(r"\W+$",_RE_FLAGS)
+
+# reference markers for language
+_RE_REF_LANG=re.compile(r"\{+\##([0-9]+)\}+")
+# language says in the emote are on the form "..." or langname"..." (no spaces).
+# this regex returns in groups (langname, say), where langname can be empty.
+_RE_LANGUAGE=re.compile(r"(?:\((\w+)\))*(\".+?\")")
+
+# the emote parser works in two steps:
+# 1) convert the incoming emote into an intermediary
+# form with all object references mapped to ids.
+# 2) for every person seeing the emote, parse this
+# intermediary form into the one valid for that char.
+
+
+
[docs]defordered_permutation_regex(sentence):
+ """
+ Builds a regex that matches 'ordered permutations' of a sentence's
+ words.
+
+ Args:
+ sentence (str): The sentence to build a match pattern to
+
+ Returns:
+ regex (re object): Compiled regex object represented the
+ possible ordered permutations of the sentence, from longest to
+ shortest.
+ Example:
+ The sdesc_regex for an sdesc of " very tall man" will
+ result in the following allowed permutations,
+ regex-matched in inverse order of length (case-insensitive):
+ "the very tall man", "the very tall", "very tall man",
+ "very tall", "the very", "tall man", "the", "very", "tall",
+ and "man".
+ We also add regex to make sure it also accepts num-specifiers,
+ like /2-tall.
+
+ """
+ # escape {#nnn} markers from sentence, replace with nnn
+ sentence=_RE_REF.sub(r"\1",sentence)
+ # escape {##nnn} markers, replace with nnn
+ sentence=_RE_REF_LANG.sub(r"\1",sentence)
+ # escape self-ref marker from sentence
+ sentence=_RE_SELF_REF.sub(r"",sentence)
+
+ # ordered permutation algorithm
+ words=sentence.split()
+ combinations=itertools.product((True,False),repeat=len(words))
+ solution=[]
+ forcombinationincombinations:
+ comb=[]
+ foriword,wordinenumerate(words):
+ ifcombination[iword]:
+ comb.append(word)
+ elifcomb:
+ break
+ ifcomb:
+ solution.append(
+ _PREFIX
+ +r"[0-9]*%s*%s(?=\W|$)+"%(_NUM_SEP,re_escape(" ".join(comb)).rstrip("\\"))
+ )
+
+ # combine into a match regex, first matching the longest down to the shortest components
+ regex=r"|".join(sorted(set(solution),key=lambdaitem:(-len(item),item)))
+ returnregex
+
+
+
[docs]defregex_tuple_from_key_alias(obj):
+ """
+ This will build a regex tuple for any object, not just from those
+ with sdesc/recog handlers. It's used as a legacy mechanism for
+ being able to mix this contrib with objects not using sdescs, but
+ note that creating the ordered permutation regex dynamically for
+ every object will add computational overhead.
+
+ Args:
+ obj (Object): This object's key and eventual aliases will
+ be used to build the tuple.
+
+ Returns:
+ regex_tuple (tuple): A tuple
+ (ordered_permutation_regex, obj, key/alias)
+
+ """
+ return(
+ re.compile(ordered_permutation_regex(" ".join([obj.key]+obj.aliases.all())),_RE_FLAGS),
+ obj,
+ obj.key,
+ )
+
+
+
[docs]defparse_language(speaker,emote):
+ """
+ Parse the emote for language. This is
+ used with a plugin for handling languages.
+
+ Args:
+ speaker (Object): The object speaking.
+ emote (str): An emote possibly containing
+ language references.
+
+ Returns:
+ (emote, mapping) (tuple): A tuple where the
+ `emote` is the emote string with all says
+ (including quotes) replaced with reference
+ markers on the form {##n} where n is a running
+ number. The `mapping` is a dictionary between
+ the markers and a tuple (langname, saytext), where
+ langname can be None.
+ Raises:
+ rplanguage.LanguageError: If an invalid language was specified.
+
+ Notes:
+ Note that no errors are raised if the wrong language identifier
+ is given.
+ This data, together with the identity of the speaker, is
+ intended to be used by the "listener" later, since with this
+ information the language skill of the speaker can be offset to
+ the language skill of the listener to determine how much
+ information is actually conveyed.
+
+ """
+ # escape mapping syntax on the form {##id} if it exists already in emote,
+ # if so it is replaced with just "id".
+ emote=_RE_REF_LANG.sub(r"\1",emote)
+
+ errors=[]
+ mapping={}
+ forimatch,say_matchinenumerate(reversed(list(_RE_LANGUAGE.finditer(emote)))):
+ # process matches backwards to be able to replace
+ # in-place without messing up indexes for future matches
+ # note that saytext includes surrounding "...".
+ langname,saytext=say_match.groups()
+ istart,iend=say_match.start(),say_match.end()
+ # the key is simply the running match in the emote
+ key="##%i"%imatch
+ # replace say with ref markers in emote
+ emote=emote[:istart]+"{%s}"%key+emote[iend:]
+ mapping[key]=(langname,saytext)
+
+ iferrors:
+ # catch errors and report
+ raiseLanguageError("\n".join(errors))
+
+ # at this point all says have been replaced with {##nn} markers
+ # and mapping maps 1:1 to this.
+ returnemote,mapping
+
+
+
[docs]defparse_sdescs_and_recogs(sender,candidates,string,search_mode=False):
+ """
+ Read a raw emote and parse it into an intermediary
+ format for distributing to all observers.
+
+ Args:
+ sender (Object): The object sending the emote. This object's
+ recog data will be considered in the parsing.
+ candidates (iterable): A list of objects valid for referencing
+ in the emote.
+ string (str): The string (like an emote) we want to analyze for keywords.
+ search_mode (bool, optional): If `True`, the "emote" is a query string
+ we want to analyze. If so, the return value is changed.
+
+ Returns:
+ (emote, mapping) (tuple): If `search_mode` is `False`
+ (default), a tuple where the emote is the emote string, with
+ all references replaced with internal-representation {#dbref}
+ markers and mapping is a dictionary `{"#dbref":obj, ...}`.
+ result (list): If `search_mode` is `True` we are
+ performing a search query on `string`, looking for a specific
+ object. A list with zero, one or more matches.
+
+ Raises:
+ EmoteException: For various ref-matching errors.
+
+ Notes:
+ The parser analyzes and should understand the following
+ _PREFIX-tagged structures in the emote:
+ - self-reference (/me)
+ - recogs (any part of it) stored on emoter, matching obj in `candidates`.
+ - sdesc (any part of it) from any obj in `candidates`.
+ - N-sdesc, N-recog separating multi-matches (1-tall, 2-tall)
+ - says, "..." are
+
+ """
+ # Load all candidate regex tuples [(regex, obj, sdesc/recog),...]
+ candidate_regexes=(
+ ([(_RE_SELF_REF,sender,sender.sdesc.get())]ifhasattr(sender,"sdesc")else[])
+ +(
+ [sender.recog.get_regex_tuple(obj)forobjincandidates]
+ ifhasattr(sender,"recog")
+ else[]
+ )
+ +[obj.sdesc.get_regex_tuple()forobjincandidatesifhasattr(obj,"sdesc")]
+ +[
+ regex_tuple_from_key_alias(obj)# handle objects without sdescs
+ forobjincandidates
+ ifnot(hasattr(obj,"recog")andhasattr(obj,"sdesc"))
+ ]
+ )
+
+ # filter out non-found data
+ candidate_regexes=[tupfortupincandidate_regexesiftup]
+
+ # escape mapping syntax on the form {#id} if it exists already in emote,
+ # if so it is replaced with just "id".
+ string=_RE_REF.sub(r"\1",string)
+ # escape loose { } brackets since this will clash with formatting
+ string=_RE_LEFT_BRACKETS.sub("{{",string)
+ string=_RE_RIGHT_BRACKETS.sub("}}",string)
+
+ # we now loop over all references and analyze them
+ mapping={}
+ errors=[]
+ obj=None
+ nmatches=0
+ formarker_matchinreversed(list(_RE_OBJ_REF_START.finditer(string))):
+ # we scan backwards so we can replace in-situ without messing
+ # up later occurrences. Given a marker match, query from
+ # start index forward for all candidates.
+
+ # first see if there is a number given (e.g. 1-tall)
+ num_identifier,_=marker_match.groups("")# return "" if no match, rather than None
+ istart0=marker_match.start()
+ istart=istart0
+
+ # loop over all candidate regexes and match against the string following the match
+ matches=((reg.match(string[istart:]),obj,text)forreg,obj,textincandidate_regexes)
+
+ # score matches by how long part of the string was matched
+ matches=[(match.end()ifmatchelse-1,obj,text)formatch,obj,textinmatches]
+ maxscore=max(scoreforscore,obj,textinmatches)
+
+ # we have a valid maxscore, extract all matches with this value
+ bestmatches=[(obj,text)forscore,obj,textinmatchesifmaxscore==score!=-1]
+ nmatches=len(bestmatches)
+
+ ifnotnmatches:
+ # no matches
+ obj=None
+ nmatches=0
+ elifnmatches==1:
+ # an exact match.
+ obj=bestmatches[0][0]
+ nmatches=1
+ elifall(bestmatches[0][0].id==obj.idforobj,textinbestmatches):
+ # multi-match but all matches actually reference the same
+ # obj (could happen with clashing recogs + sdescs)
+ obj=bestmatches[0][0]
+ nmatches=1
+ else:
+ # multi-match.
+ # was a numerical identifier given to help us separate the multi-match?
+ inum=min(max(0,int(num_identifier)-1),nmatches-1)ifnum_identifierelseNone
+ ifinumisnotNone:
+ # A valid inum is given. Use this to separate data.
+ obj=bestmatches[inum][0]
+ nmatches=1
+ else:
+ # no identifier given - a real multimatch.
+ obj=bestmatches
+
+ ifsearch_mode:
+ # single-object search mode. Don't continue loop.
+ break
+ elifnmatches==0:
+ errors.append(_EMOTE_NOMATCH_ERROR.format(ref=marker_match.group()))
+ elifnmatches==1:
+ key="#%i"%obj.id
+ string=string[:istart0]+"{%s}"%key+string[istart+maxscore:]
+ mapping[key]=obj
+ else:
+ refname=marker_match.group()
+ reflist=[
+ "%s%s%s (%s%s)"
+ %(
+ inum+1,
+ _NUM_SEP,
+ _RE_PREFIX.sub("",refname),
+ text,
+ " (%s)"%sender.keyifsender==obelse"",
+ )
+ forinum,(ob,text)inenumerate(obj)
+ ]
+ errors.append(
+ _EMOTE_MULTIMATCH_ERROR.format(
+ ref=marker_match.group(),reflist="\n ".join(reflist)
+ )
+ )
+ ifsearch_mode:
+ # return list of object(s) matching
+ ifnmatches==0:
+ return[]
+ elifnmatches==1:
+ return[obj]
+ else:
+ return[tup[0]fortupinobj]
+
+ iferrors:
+ # make sure to not let errors through.
+ raiseEmoteError("\n".join(errors))
+
+ # at this point all references have been replaced with {#xxx} markers and the mapping contains
+ # a 1:1 mapping between those inline markers and objects.
+ returnstring,mapping
+
+
+
[docs]defsend_emote(sender,receivers,emote,anonymous_add="first",**kwargs):
+ """
+ Main access function for distribute an emote.
+
+ Args:
+ sender (Object): The one sending the emote.
+ receivers (iterable): Receivers of the emote. These
+ will also form the basis for which sdescs are
+ 'valid' to use in the emote.
+ emote (str): The raw emote string as input by emoter.
+ anonymous_add (str or None, optional): If `sender` is not
+ self-referencing in the emote, this will auto-add
+ `sender`'s data to the emote. Possible values are
+ - None: No auto-add at anonymous emote
+ - 'last': Add sender to the end of emote as [sender]
+ - 'first': Prepend sender to start of emote.
+
+ """
+ try:
+ emote,obj_mapping=parse_sdescs_and_recogs(sender,receivers,emote)
+ emote,language_mapping=parse_language(sender,emote)
+ except(EmoteError,LanguageError)aserr:
+ # handle all error messages, don't hide actual coding errors
+ sender.msg(str(err))
+ return
+ # we escape the object mappings since we'll do the language ones first
+ # (the text could have nested object mappings).
+ emote=_RE_REF.sub(r"{{#\1}}",emote)
+ # if anonymous_add is passed as a kwarg, collect and remove it from kwargs
+ if'anonymous_add'inkwargs:
+ anonymous_add=kwargs.pop('anonymous_add')
+ ifanonymous_addandnot"#%i"%sender.idinobj_mapping:
+ # no self-reference in the emote - add to the end
+ key="#%i"%sender.id
+ obj_mapping[key]=sender
+ ifanonymous_add=="first":
+ possessive=""ifemote.startswith("'")else" "
+ emote="%s%s%s"%("{{%s}}"%key,possessive,emote)
+ else:
+ emote="%s [%s]"%(emote,"{{%s}}"%key)
+
+ # broadcast emote to everyone
+ forreceiverinreceivers:
+ # first handle the language mapping, which always produce different keys ##nn
+ receiver_lang_mapping={}
+ try:
+ process_language=receiver.process_language
+ exceptAttributeError:
+ process_language=_dummy_process
+ forkey,(langname,saytext)inlanguage_mapping.items():
+ # color says
+ receiver_lang_mapping[key]=process_language(saytext,sender,langname)
+ # map the language {##num} markers. This will convert the escaped sdesc markers on
+ # the form {{#num}} to {#num} markers ready to sdescmat in the next step.
+ sendemote=emote.format(**receiver_lang_mapping)
+
+ # handle sdesc mappings. we make a temporary copy that we can modify
+ try:
+ process_sdesc=receiver.process_sdesc
+ exceptAttributeError:
+ process_sdesc=_dummy_process
+
+ try:
+ process_recog=receiver.process_recog
+ exceptAttributeError:
+ process_recog=_dummy_process
+
+ try:
+ recog_get=receiver.recog.get
+ receiver_sdesc_mapping=dict(
+ (ref,process_recog(recog_get(obj),obj))forref,objinobj_mapping.items()
+ )
+ exceptAttributeError:
+ receiver_sdesc_mapping=dict(
+ (
+ ref,
+ process_sdesc(obj.sdesc.get(),obj)
+ ifhasattr(obj,"sdesc")
+ elseprocess_sdesc(obj.key,obj),
+ )
+ forref,objinobj_mapping.items()
+ )
+ # make sure receiver always sees their real name
+ rkey="#%i"%receiver.id
+ ifrkeyinreceiver_sdesc_mapping:
+ receiver_sdesc_mapping[rkey]=process_sdesc(receiver.key,receiver)
+
+ # do the template replacement of the sdesc/recog {#num} markers
+ receiver.msg(sendemote.format(**receiver_sdesc_mapping),from_obj=sender,**kwargs)
+
+
+# ------------------------------------------------------------
+# Handlers for sdesc and recog
+# ------------------------------------------------------------
+
+
+
[docs]classSdescHandler(object):
+ """
+ This Handler wraps all operations with sdescs. We
+ need to use this since we do a lot preparations on
+ sdescs when updating them, in order for them to be
+ efficient to search for and query.
+
+ The handler stores data in the following Attributes
+
+ _sdesc - a string
+ _regex - an empty dictionary
+
+ """
+
+
[docs]def__init__(self,obj):
+ """
+ Initialize the handler
+
+ Args:
+ obj (Object): The entity on which this handler is stored.
+
+ """
+ self.obj=obj
+ self.sdesc=""
+ self.sdesc_regex=""
+ self._cache()
[docs]defadd(self,sdesc,max_length=60):
+ """
+ Add a new sdesc to object, replacing the old one.
+
+ Args:
+ sdesc (str): The sdesc to set. This may be stripped
+ of control sequences before setting.
+ max_length (int, optional): The max limit of the sdesc.
+
+ Returns:
+ sdesc (str): The actually set sdesc.
+
+ Raises:
+ SdescError: If the sdesc is empty, can not be set or is
+ longer than `max_length`.
+
+ """
+ # strip emote components from sdesc
+ sdesc=_RE_REF.sub(
+ r"\1",
+ _RE_REF_LANG.sub(
+ r"\1",
+ _RE_SELF_REF.sub(r"",_RE_LANGUAGE.sub(r"",_RE_OBJ_REF_START.sub(r"",sdesc))),
+ ),
+ )
+
+ # make an sdesc clean of ANSI codes
+ cleaned_sdesc=ansi.strip_ansi(sdesc)
+
+ ifnotcleaned_sdesc:
+ raiseSdescError("Short desc cannot be empty.")
+
+ iflen(cleaned_sdesc)>max_length:
+ raiseSdescError(
+ "Short desc can max be %i chars long (was %i chars)."
+ %(max_length,len(cleaned_sdesc))
+ )
+
+ # store to attributes
+ sdesc_regex=ordered_permutation_regex(cleaned_sdesc)
+ self.obj.attributes.add("_sdesc",sdesc)
+ self.obj.attributes.add("_sdesc_regex",sdesc_regex)
+ # local caching
+ self.sdesc=sdesc
+ self.sdesc_regex=re.compile(sdesc_regex,_RE_FLAGS)
+
+ returnsdesc
+
+
[docs]defget(self):
+ """
+ Simple getter. The sdesc should never be allowed to
+ be empty, but if it is we must fall back to the key.
+
+ """
+ returnself.sdescorself.obj.key
[docs]classRecogHandler(object):
+ """
+ This handler manages the recognition mapping
+ of an Object.
+
+ The handler stores data in Attributes as dictionaries of
+ the following names:
+
+ _recog_ref2recog
+ _recog_obj2recog
+ _recog_obj2regex
+
+ """
+
+
[docs]def__init__(self,obj):
+ """
+ Initialize the handler
+
+ Args:
+ obj (Object): The entity on which this handler is stored.
+
+ """
+ self.obj=obj
+ # mappings
+ self.ref2recog={}
+ self.obj2regex={}
+ self.obj2recog={}
+ self._cache()
[docs]defadd(self,obj,recog,max_length=60):
+ """
+ Assign a custom recog (nick) to the given object.
+
+ Args:
+ obj (Object): The object ot associate with the recog
+ string. This is usually determined from the sdesc in the
+ room by a call to parse_sdescs_and_recogs, but can also be
+ given.
+ recog (str): The replacement string to use with this object.
+ max_length (int, optional): The max length of the recog string.
+
+ Returns:
+ recog (str): The (possibly cleaned up) recog string actually set.
+
+ Raises:
+ SdescError: When recog could not be set or sdesc longer
+ than `max_length`.
+
+ """
+ ifnotobj.access(self.obj,"enable_recog",default=True):
+ raiseSdescError("This person is unrecognizeable.")
+
+ # strip emote components from recog
+ recog=_RE_REF.sub(
+ r"\1",
+ _RE_REF_LANG.sub(
+ r"\1",
+ _RE_SELF_REF.sub(r"",_RE_LANGUAGE.sub(r"",_RE_OBJ_REF_START.sub(r"",recog))),
+ ),
+ )
+
+ # make an recog clean of ANSI codes
+ cleaned_recog=ansi.strip_ansi(recog)
+
+ ifnotcleaned_recog:
+ raiseSdescError("Recog string cannot be empty.")
+
+ iflen(cleaned_recog)>max_length:
+ raiseRecogError(
+ "Recog string cannot be longer than %i chars (was %i chars)"
+ %(max_length,len(cleaned_recog))
+ )
+
+ # mapping #dbref:obj
+ key="#%i"%obj.id
+ self.obj.attributes.get("_recog_ref2recog",default={})[key]=recog
+ self.obj.attributes.get("_recog_obj2recog",default={})[obj]=recog
+ regex=ordered_permutation_regex(cleaned_recog)
+ self.obj.attributes.get("_recog_obj2regex",default={})[obj]=regex
+ # local caching
+ self.ref2recog[key]=recog
+ self.obj2recog[obj]=recog
+ self.obj2regex[obj]=re.compile(regex,_RE_FLAGS)
+ returnrecog
+
+
[docs]defget(self,obj):
+ """
+ Get recog replacement string, if one exists, otherwise
+ get sdesc and as a last resort, the object's key.
+
+ Args:
+ obj (Object): The object, whose sdesc to replace
+ Returns:
+ recog (str): The replacement string to use.
+
+ Notes:
+ This method will respect a "enable_recog" lock set on
+ `obj` (True by default) in order to turn off recog
+ mechanism. This is useful for adding masks/hoods etc.
+ """
+ ifobj.access(self.obj,"enable_recog",default=True):
+ # check an eventual recog_masked lock on the object
+ # to avoid revealing masked characters. If lock
+ # does not exist, pass automatically.
+ returnself.obj2recog.get(obj,obj.sdesc.get()ifhasattr(obj,"sdesc")elseobj.key)
+ else:
+ # recog_mask log not passed, disable recog
+ returnobj.sdesc.get()ifhasattr(obj,"sdesc")elseobj.key
+
+
[docs]defall(self):
+ """
+ Get a mapping of the recogs stored in handler.
+
+ Returns:
+ recogs (dict): A mapping of {recog: obj} stored in handler.
+
+ """
+ return{self.obj2recog[obj]:objforobjinself.obj2recog.keys()}
+
+
[docs]defremove(self,obj):
+ """
+ Clear recog for a given object.
+
+ Args:
+ obj (Object): The object for which to remove recog.
+ """
+ ifobjinself.obj2recog:
+ delself.obj.db._recog_obj2recog[obj]
+ delself.obj.db._recog_obj2regex[obj]
+ delself.obj.db._recog_ref2recog["#%i"%obj.id]
+ self._cache()
[docs]defparse(self):
+ "strip extra whitespace"
+ self.args=self.args.strip()
+
+
+
[docs]classCmdEmote(RPCommand):# replaces the main emote
+ """
+ Emote an action, allowing dynamic replacement of
+ text in the emote.
+
+ Usage:
+ emote text
+
+ Example:
+ emote /me looks around.
+ emote With a flurry /me attacks /tall man with his sword.
+ emote "Hello", /me says.
+
+ Describes an event in the world. This allows the use of /ref
+ markers to replace with the short descriptions or recognized
+ strings of objects in the same room. These will be translated to
+ emotes to match each person seeing it. Use "..." for saying
+ things and langcode"..." without spaces to say something in
+ a different language.
+
+ """
+
+ key="emote"
+ aliases=[":"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ "Perform the emote."
+ ifnotself.args:
+ self.caller.msg("What do you want to do?")
+ else:
+ # we also include ourselves here.
+ emote=self.args
+ targets=self.caller.location.contents
+ ifnotemote.endswith((".","?","!")):# If emote is not punctuated,
+ emote="%s."%emote# add a full-stop for good measure.
+ send_emote(self.caller,targets,emote,anonymous_add="first")
+
+
+
[docs]classCmdSay(RPCommand):# replaces standard say
+ """
+ speak as your character
+
+ Usage:
+ say <message>
+
+ Talk to those in your current location.
+ """
+
+ key="say"
+ aliases=['"',"'"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ "Run the say command"
+
+ caller=self.caller
+
+ ifnotself.args:
+ caller.msg("Say what?")
+ return
+
+ # calling the speech modifying hook
+ speech=caller.at_before_say(self.args)
+ # preparing the speech with sdesc/speech parsing.
+ targets=self.caller.location.contents
+ send_emote(self.caller,targets,speech,anonymous_add=None)
+
+
+
[docs]classCmdSdesc(RPCommand):# set/look at own sdesc
+ """
+ Assign yourself a short description (sdesc).
+
+ Usage:
+ sdesc <short description>
+
+ Assigns a short description to yourself.
+
+ """
+
+ key="sdesc"
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ "Assign the sdesc"
+ caller=self.caller
+ ifnotself.args:
+ caller.msg("Usage: sdesc <sdesc-text>")
+ return
+ else:
+ # strip non-alfanum chars from end of sdesc
+ sdesc=_RE_CHAREND.sub("",self.args)
+ try:
+ sdesc=caller.sdesc.add(sdesc)
+ exceptSdescErroraserr:
+ caller.msg(err)
+ return
+ exceptAttributeError:
+ caller.msg(f"Cannot set sdesc on {caller.key}.")
+ return
+ caller.msg("%s's sdesc was set to '%s'."%(caller.key,sdesc))
+
+
+
[docs]classCmdPose(RPCommand):# set current pose and default pose
+ """
+ Set a static pose
+
+ Usage:
+ pose <pose>
+ pose default <pose>
+ pose reset
+ pose obj = <pose>
+ pose default obj = <pose>
+ pose reset obj =
+
+ Examples:
+ pose leans against the tree
+ pose is talking to the barkeep.
+ pose box = is sitting on the floor.
+
+ Set a static pose. This is the end of a full sentence that starts
+ with your sdesc. If no full stop is given, it will be added
+ automatically. The default pose is the pose you get when using
+ pose reset. Note that you can use sdescs/recogs to reference
+ people in your pose, but these always appear as that person's
+ sdesc in the emote, regardless of who is seeing it.
+
+ """
+
+ key="pose"
+
+
[docs]deffunc(self):
+ "Create the pose"
+ caller=self.caller
+ pose=self.args
+ target=self.target
+ ifnotposeandnotself.reset:
+ caller.msg("Usage: pose <pose-text> OR pose obj = <pose-text>")
+ return
+
+ ifnotpose.endswith("."):
+ pose="%s."%pose
+ iftarget:
+ # affect something else
+ target=caller.search(target)
+ ifnottarget:
+ return
+ ifnottarget.access(caller,"edit"):
+ caller.msg("You can't pose that.")
+ return
+ else:
+ target=caller
+
+ ifnottarget.attributes.has("pose"):
+ caller.msg("%s cannot be posed."%target.key)
+ return
+
+ target_name=target.sdesc.get()ifhasattr(target,"sdesc")elsetarget.key
+ # set the pose
+ ifself.reset:
+ pose=target.db.pose_default
+ target.db.pose=pose
+ elifself.default:
+ target.db.pose_default=pose
+ caller.msg("Default pose is now '%s%s'."%(target_name,pose))
+ return
+ else:
+ # set the pose. We do one-time ref->sdesc mapping here.
+ parsed,mapping=parse_sdescs_and_recogs(caller,caller.location.contents,pose)
+ mapping=dict(
+ (ref,obj.sdesc.get()ifhasattr(obj,"sdesc")elseobj.key)
+ forref,objinmapping.items()
+ )
+ pose=parsed.format(**mapping)
+
+ iflen(target_name)+len(pose)>60:
+ caller.msg("Your pose '%s' is too long."%pose)
+ return
+
+ target.db.pose=pose
+
+ caller.msg("Pose will read '%s%s'."%(target_name,pose))
+
+
+
[docs]classCmdRecog(RPCommand):# assign personal alias to object in room
+ """
+ Recognize another person in the same room.
+
+ Usage:
+ recog
+ recog sdesc as alias
+ forget alias
+
+ Example:
+ recog tall man as Griatch
+ forget griatch
+
+ This will assign a personal alias for a person, or forget said alias.
+ Using the command without arguments will list all current recogs.
+
+ """
+
+ key="recog"
+ aliases=["recognize","forget"]
+
+
[docs]defparse(self):
+ "Parse for the sdesc as alias structure"
+ self.sdesc,self.alias="",""
+ if" as "inself.args:
+ self.sdesc,self.alias=[part.strip()forpartinself.args.split(" as ",2)]
+ elifself.args:
+ # try to split by space instead
+ try:
+ self.sdesc,self.alias=[part.strip()forpartinself.args.split(None,1)]
+ exceptValueError:
+ self.sdesc,self.alias=self.args.strip(),""
+
+
[docs]deffunc(self):
+ "Assign the recog"
+ caller=self.caller
+ alias=self.alias.rstrip(".?!")
+ sdesc=self.sdesc
+
+ recog_mode=self.cmdstring!="forget"andaliasandsdesc
+ forget_mode=self.cmdstring=="forget"andsdesc
+ list_mode=notself.args
+
+ ifnot(recog_modeorforget_modeorlist_mode):
+ caller.msg("Usage: recog, recog <sdesc> as <alias> or forget <alias>")
+ return
+
+ iflist_mode:
+ # list all previously set recogs
+ all_recogs=caller.recog.all()
+ ifnotall_recogs:
+ caller.msg(
+ "You recognize no-one. ""(Use 'recog <sdesc> as <alias>' to recognize people."
+ )
+ else:
+ # note that we don't skip those failing enable_recog lock here,
+ # because that would actually reveal more than we want.
+ lst="\n".join(
+ " {} ({})".format(key,obj.sdesc.get()ifhasattr(obj,"sdesc")elseobj.key)
+ forkey,objinall_recogs.items()
+ )
+ caller.msg(
+ f"Currently recognized (use 'recog <sdesc> as <alias>' to add "
+ f"new and 'forget <alias>' to remove):\n{lst}"
+ )
+ return
+
+ prefixed_sdesc=sdescifsdesc.startswith(_PREFIX)else_PREFIX+sdesc
+ candidates=caller.location.contents
+ matches=parse_sdescs_and_recogs(caller,candidates,prefixed_sdesc,search_mode=True)
+ nmatches=len(matches)
+ # handle 0 and >1 matches
+ ifnmatches==0:
+ caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc))
+ elifnmatches>1:
+ reflist=[
+ "{}{}{} ({}{})".format(
+ inum+1,
+ _NUM_SEP,
+ _RE_PREFIX.sub("",sdesc),
+ caller.recog.get(obj),
+ " (%s)"%caller.keyifcaller==objelse"",
+ )
+ forinum,objinenumerate(matches)
+ ]
+ caller.msg(_EMOTE_MULTIMATCH_ERROR.format(ref=sdesc,reflist="\n ".join(reflist)))
+
+ else:
+ # one single match
+ obj=matches[0]
+ ifnotobj.access(self.obj,"enable_recog",default=True):
+ # don't apply recog if object doesn't allow it (e.g. by being masked).
+ caller.msg("It's impossible to recognize them.")
+ return
+ ifforget_mode:
+ # remove existing recog
+ caller.recog.remove(obj)
+ caller.msg("%s will now know them only as '%s'."%(caller.key,obj.recog.get(obj)))
+ else:
+ # set recog
+ sdesc=obj.sdesc.get()ifhasattr(obj,"sdesc")elseobj.key
+ try:
+ alias=caller.recog.add(obj,alias)
+ exceptRecogErroraserr:
+ caller.msg(err)
+ return
+ caller.msg("%s will now remember |w%s|n as |w%s|n."%(caller.key,sdesc,alias))
+
+
+
[docs]classCmdMask(RPCommand):
+ """
+ Wear a mask
+
+ Usage:
+ mask <new sdesc>
+ unmask
+
+ This will put on a mask to hide your identity. When wearing
+ a mask, your sdesc will be replaced by the sdesc you pick and
+ people's recognitions of you will be disabled.
+
+ """
+
+ key="mask"
+ aliases=["unmask"]
+
+
[docs]deffunc(self):
+ caller=self.caller
+ ifself.cmdstring=="mask":
+ # wear a mask
+ ifnotself.args:
+ caller.msg("Usage: (un)mask sdesc")
+ return
+ ifcaller.db.unmasked_sdesc:
+ caller.msg("You are already wearing a mask.")
+ return
+ sdesc=_RE_CHAREND.sub("",self.args)
+ sdesc="%s |H[masked]|n"%sdesc
+ iflen(sdesc)>60:
+ caller.msg("Your masked sdesc is too long.")
+ return
+ caller.db.unmasked_sdesc=caller.sdesc.get()
+ caller.locks.add("enable_recog:false()")
+ caller.sdesc.add(sdesc)
+ caller.msg("You wear a mask as '%s'."%sdesc)
+ else:
+ # unmask
+ old_sdesc=caller.db.unmasked_sdesc
+ ifnotold_sdesc:
+ caller.msg("You are not wearing a mask.")
+ return
+ delcaller.db.unmasked_sdesc
+ caller.locks.remove("enable_recog")
+ caller.sdesc.add(old_sdesc)
+ caller.msg("You remove your mask and are again '%s'."%old_sdesc)
+
+
+
[docs]classRPSystemCmdSet(CmdSet):
+ """
+ Mix-in for adding rp-commands to default cmdset.
+ """
+
+
[docs]classContribRPObject(DefaultObject):
+ """
+ This class is meant as a mix-in or parent for objects in an
+ rp-heavy game. It implements the base functionality for poses.
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called at initial creation.
+ """
+ super().at_object_creation()
+
+ # emoting/recog data
+ self.db.pose=""
+ self.db.pose_default="is here."
+
+
[docs]defsearch(
+ self,
+ searchdata,
+ global_search=False,
+ use_nicks=True,
+ typeclass=None,
+ location=None,
+ attribute_name=None,
+ quiet=False,
+ exact=False,
+ candidates=None,
+ nofound_string=None,
+ multimatch_string=None,
+ use_dbref=None,
+ ):
+ """
+ Returns an Object matching a search string/condition, taking
+ sdescs into account.
+
+ Perform a standard object search in the database, handling
+ multiple results and lack thereof gracefully. By default, only
+ objects in the current `location` of `self` or its inventory are searched for.
+
+ Args:
+ searchdata (str or obj): Primary search criterion. Will be matched
+ against `object.key` (with `object.aliases` second) unless
+ the keyword attribute_name specifies otherwise.
+ **Special strings:**
+ - `#<num>`: search by unique dbref. This is always
+ a global search.
+ - `me,self`: self-reference to this object
+ - `<num>-<string>` - can be used to differentiate
+ between multiple same-named matches
+ global_search (bool): Search all objects globally. This is overruled
+ by `location` keyword.
+ use_nicks (bool): Use nickname-replace (nicktype "object") on `searchdata`.
+ typeclass (str or Typeclass, or list of either): Limit search only
+ to `Objects` with this typeclass. May be a list of typeclasses
+ for a broader search.
+ location (Object or list): Specify a location or multiple locations
+ to search. Note that this is used to query the *contents* of a
+ location and will not match for the location itself -
+ if you want that, don't set this or use `candidates` to specify
+ exactly which objects should be searched.
+ attribute_name (str): Define which property to search. If set, no
+ key+alias search will be performed. This can be used
+ to search database fields (db_ will be automatically
+ appended), and if that fails, it will try to return
+ objects having Attributes with this name and value
+ equal to searchdata. A special use is to search for
+ "key" here if you want to do a key-search without
+ including aliases.
+ quiet (bool): don't display default error messages - this tells the
+ search method that the user wants to handle all errors
+ themselves. It also changes the return value type, see
+ below.
+ exact (bool): if unset (default) - prefers to match to beginning of
+ string rather than not matching at all. If set, requires
+ exact matching of entire string.
+ candidates (list of objects): this is an optional custom list of objects
+ to search (filter) between. It is ignored if `global_search`
+ is given. If not set, this list will automatically be defined
+ to include the location, the contents of location and the
+ caller's contents (inventory).
+ nofound_string (str): optional custom string for not-found error message.
+ multimatch_string (str): optional custom string for multimatch error header.
+ use_dbref (bool or None): If None, only turn off use_dbref if we are of a lower
+ permission than Builder. Otherwise, honor the True/False value.
+
+ Returns:
+ match (Object, None or list): will return an Object/None if `quiet=False`,
+ otherwise it will return a list of 0, 1 or more matches.
+
+ Notes:
+ To find Accounts, use eg. `evennia.account_search`. If
+ `quiet=False`, error messages will be handled by
+ `settings.SEARCH_AT_RESULT` and echoed automatically (on
+ error, return will be `None`). If `quiet=True`, the error
+ messaging is assumed to be handled by the caller.
+
+ """
+ is_string=isinstance(searchdata,str)
+
+ ifis_string:
+ # searchdata is a string; wrap some common self-references
+ ifsearchdata.lower()in("here",):
+ return[self.location]ifquietelseself.location
+ ifsearchdata.lower()in("me","self"):
+ return[self]ifquietelseself
+
+ ifuse_nicks:
+ # do nick-replacement on search
+ searchdata=self.nicks.nickreplace(
+ searchdata,categories=("object","account"),include_account=True
+ )
+
+ ifglobal_searchor(
+ is_string
+ andsearchdata.startswith("#")
+ andlen(searchdata)>1
+ andsearchdata[1:].isdigit()
+ ):
+ # only allow exact matching if searching the entire database
+ # or unique #dbrefs
+ exact=True
+ elifcandidatesisNone:
+ # no custom candidates given - get them automatically
+ iflocation:
+ # location(s) were given
+ candidates=[]
+ forobjinmake_iter(location):
+ candidates.extend(obj.contents)
+ else:
+ # local search. Candidates are taken from
+ # self.contents, self.location and
+ # self.location.contents
+ location=self.location
+ candidates=self.contents
+ iflocation:
+ candidates=candidates+[location]+location.contents
+ else:
+ # normally we don't need this since we are
+ # included in location.contents
+ candidates.append(self)
+
+ # the sdesc-related substitution
+ is_builder=self.locks.check_lockstring(self,"perm(Builder)")
+ use_dbref=is_builderifuse_dbrefisNoneelseuse_dbref
+
+ defsearch_obj(string):
+ "helper wrapper for searching"
+ returnObjectDB.objects.object_search(
+ string,
+ attribute_name=attribute_name,
+ typeclass=typeclass,
+ candidates=candidates,
+ exact=exact,
+ use_dbref=use_dbref,
+ )
+
+ ifcandidates:
+ candidates=parse_sdescs_and_recogs(
+ self,candidates,_PREFIX+searchdata,search_mode=True
+ )
+ results=[]
+ forcandidateincandidates:
+ # we search by candidate keys here; this allows full error
+ # management and use of all kwargs - we will use searchdata
+ # in eventual error reporting later (not their keys). Doing
+ # it like this e.g. allows for use of the typeclass kwarg
+ # limiter.
+ results.extend([objforobjinsearch_obj(candidate.key)ifobjnotinresults])
+
+ ifnotresultsandis_builder:
+ # builders get a chance to search only by key+alias
+ results=search_obj(searchdata)
+ else:
+ # global searches / #drefs end up here. Global searches are
+ # only done in code, so is controlled, #dbrefs are turned off
+ # for non-Builders.
+ results=search_obj(searchdata)
+
+ ifquiet:
+ returnresults
+ return_AT_SEARCH_RESULT(
+ results,
+ self,
+ query=searchdata,
+ nofound_string=nofound_string,
+ multimatch_string=multimatch_string,
+ )
+
+
[docs]defget_display_name(self,looker,**kwargs):
+ """
+ Displays the name of the object in a viewer-aware manner.
+
+ Args:
+ looker (TypedObject): The object or account that is looking
+ at/getting inforamtion for this object.
+
+ Keyword Args:
+ pose (bool): Include the pose (if available) in the return.
+
+ Returns:
+ name (str): A string of the sdesc containing the name of the object,
+ if this is defined.
+ including the DBREF if this user is privileged to control
+ said object.
+
+ Notes:
+ The RPObject version doesn't add color to its display.
+
+ """
+ idstr="(#%s)"%self.idifself.access(looker,access_type="control")else""
+ iflooker==self:
+ sdesc=self.key
+ else:
+ try:
+ recog=looker.recog.get(self)
+ exceptAttributeError:
+ recog=None
+ sdesc=recogor(hasattr(self,"sdesc")andself.sdesc.get())orself.key
+ pose=" %s"%(self.db.poseor"")ifkwargs.get("pose",False)else""
+ return"%s%s%s"%(sdesc,idstr,pose)
+
+
[docs]defreturn_appearance(self,looker):
+ """
+ This formats a description. It is the hook a 'look' command
+ should call.
+
+ Args:
+ looker (Object): Object doing the looking.
+ """
+ ifnotlooker:
+ return""
+ # get and identify all objects
+ visible=(conforconinself.contentsifcon!=lookerandcon.access(looker,"view"))
+ exits,users,things=[],[],[]
+ forconinvisible:
+ key=con.get_display_name(looker,pose=True)
+ ifcon.destination:
+ exits.append(key)
+ elifcon.has_account:
+ users.append(key)
+ else:
+ things.append(key)
+ # get description, build string
+ string="|c%s|n\n"%self.get_display_name(looker,pose=True)
+ desc=self.db.desc
+ ifdesc:
+ string+="%s"%desc
+ ifexits:
+ string+="\n|wExits:|n "+", ".join(exits)
+ ifusersorthings:
+ string+="\n "+"\n ".join(users+things)
+ returnstring
[docs]classContribRPCharacter(DefaultCharacter,ContribRPObject):
+ """
+ This is a character class that has poses, sdesc and recog.
+ """
+
+ # Handlers
+
[docs]defget_display_name(self,looker,**kwargs):
+ """
+ Displays the name of the object in a viewer-aware manner.
+
+ Args:
+ looker (TypedObject): The object or account that is looking
+ at/getting inforamtion for this object.
+
+ Keyword Args:
+ pose (bool): Include the pose (if available) in the return.
+
+ Returns:
+ name (str): A string of the sdesc containing the name of the object,
+ if this is defined.
+ including the DBREF if this user is privileged to control
+ said object.
+
+ Notes:
+ The RPCharacter version of this method colors its display to make
+ characters stand out from other objects.
+
+ """
+ idstr="(#%s)"%self.idifself.access(looker,access_type="control")else""
+ iflooker==self:
+ sdesc=self.key
+ else:
+ try:
+ recog=looker.recog.get(self)
+ exceptAttributeError:
+ recog=None
+ sdesc=recogor(hasattr(self,"sdesc")andself.sdesc.get())orself.key
+ pose=" %s"%(self.db.poseor"is here.")ifkwargs.get("pose",False)else""
+ return"|c%s|n%s%s"%(sdesc,idstr,pose)
[docs]defat_before_say(self,message,**kwargs):
+ """
+ Called before the object says or whispers anything, return modified message.
+
+ Args:
+ message (str): The suggested say/whisper text spoken by self.
+ Keyword Args:
+ whisper (bool): If True, this is a whisper rather than a say.
+
+ """
+ ifkwargs.get("whisper"):
+ returnf'/me whispers "{message}"'
+ returnf'/me says, "{message}"'
+
+
[docs]defprocess_sdesc(self,sdesc,obj,**kwargs):
+ """
+ Allows to customize how your sdesc is displayed (primarily by
+ changing colors).
+
+ Args:
+ sdesc (str): The sdesc to display.
+ obj (Object): The object to which the adjoining sdesc
+ belongs. If this object is equal to yourself, then
+ you are viewing yourself (and sdesc is your key).
+ This is not used by default.
+
+ Returns:
+ sdesc (str): The processed sdesc ready
+ for display.
+
+ """
+ return"|b%s|n"%sdesc
+
+
[docs]defprocess_recog(self,recog,obj,**kwargs):
+ """
+ Allows to customize how a recog string is displayed.
+
+ Args:
+ recog (str): The recog string. It has already been
+ translated from the original sdesc at this point.
+ obj (Object): The object the recog:ed string belongs to.
+ This is not used by default.
+
+ Returns:
+ recog (str): The modified recog string.
+
+ """
+ returnself.process_sdesc(recog,obj)
+
+
[docs]defprocess_language(self,text,speaker,language,**kwargs):
+ """
+ Allows to process the spoken text, for example
+ by obfuscating language based on your and the
+ speaker's language skills. Also a good place to
+ put coloring.
+
+ Args:
+ text (str): The text to process.
+ speaker (Object): The object delivering the text.
+ language (str): An identifier string for the language.
+
+ Return:
+ text (str): The optionally processed text.
+
+ Notes:
+ This is designed to work together with a string obfuscator
+ such as the `obfuscate_language` or `obfuscate_whisper` in
+ the evennia.contrib.rplanguage module.
+
+ """
+ return"%s|w%s|n"%("|W(%s)"%languageiflanguageelse"",text)
Source code for evennia.contrib.security.auditing.outputs
+"""
+Auditable Server Sessions - Example Outputs
+Example methods demonstrating output destinations for logs generated by
+audited server sessions.
+
+This is designed to be a single source of events for developers to customize
+and add any additional enhancements before events are written out-- i.e. if you
+want to keep a running list of what IPs a user logs in from on account/character
+objects, or if you want to perform geoip or ASN lookups on IPs before committing,
+or tag certain events with the results of a reputational lookup, this should be
+the easiest place to do it. Write a method and invoke it via
+`settings.AUDIT_CALLBACK` to have log data objects passed to it.
+
+Evennia contribution - Johnny 2017
+"""
+fromevennia.utils.loggerimportlog_file
+importjson
+importsyslog
+
+
+
[docs]defto_file(data):
+ """
+ Writes dictionaries of data generated by an AuditedServerSession to files
+ in JSON format, bucketed by date.
+
+ Uses Evennia's native logger and writes to the default
+ log directory (~/yourgame/server/logs/ or settings.LOG_DIR)
+
+ Args:
+ data (dict): Parsed session transmission data.
+
+ """
+ # Bucket logs by day and remove objects before serialization
+ bucket=data.pop("objects")["time"].strftime("%Y-%m-%d")
+
+ # Write it
+ log_file(json.dumps(data),filename="audit_%s.log"%bucket)
+
+
+
[docs]defto_syslog(data):
+ """
+ Writes dictionaries of data generated by an AuditedServerSession to syslog.
+
+ Takes advantage of your system's native logger and writes to wherever
+ you have it configured, which is independent of Evennia.
+ Linux systems tend to write to /var/log/syslog.
+
+ If you're running rsyslog, you can configure it to dump and/or forward logs
+ to disk and/or an external data warehouse (recommended-- if your server is
+ compromised or taken down, losing your logs along with it is no help!).
+
+ Args:
+ data (dict): Parsed session transmission data.
+
+ """
+ # Remove objects before serialization
+ data.pop("objects")
+
+ # Write it out
+ syslog.syslog(json.dumps(data))
Source code for evennia.contrib.security.auditing.server
+"""
+Auditable Server Sessions:
+Extension of the stock ServerSession that yields objects representing
+user inputs and system outputs.
+
+Evennia contribution - Johnny 2017
+"""
+importos
+importre
+importsocket
+
+fromdjango.utilsimporttimezone
+fromdjango.confimportsettingsasev_settings
+fromevennia.utilsimportutils,logger,mod_import,get_evennia_version
+fromevennia.server.serversessionimportServerSession
+
+# Attributes governing auditing of commands and where to send log objects
+AUDIT_CALLBACK=getattr(
+ ev_settings,"AUDIT_CALLBACK","evennia.contrib.security.auditing.outputs.to_file"
+)
+AUDIT_IN=getattr(ev_settings,"AUDIT_IN",False)
+AUDIT_OUT=getattr(ev_settings,"AUDIT_OUT",False)
+AUDIT_ALLOW_SPARSE=getattr(ev_settings,"AUDIT_ALLOW_SPARSE",False)
+AUDIT_MASKS=[
+ {"connect":r"^[@\s]*[connect]{5,8}\s+(\".+?\"|[^\s]+)\s+(?P<secret>.+)"},
+ {"connect":r"^[@\s]*[connect]{5,8}\s+(?P<secret>[\w]+)"},
+ {"create":r"^[^@]?[create]{5,6}\s+(\w+|\".+?\")\s+(?P<secret>[\w]+)"},
+ {"create":r"^[^@]?[create]{5,6}\s+(?P<secret>[\w]+)"},
+ {"userpassword":r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P<secret>[\w]+)"},
+ {"userpassword":r"^.*new password set to '(?P<secret>[^']+)'\."},
+ {"userpassword":r"^.* has changed your password to '(?P<secret>[^']+)'\."},
+ {"password":r"^[@\s]*[password]{6,9}\s+(?P<secret>.*)"},
+]+getattr(ev_settings,"AUDIT_MASKS",[])
+
+
+ifAUDIT_CALLBACK:
+ try:
+ AUDIT_CALLBACK=getattr(
+ mod_import(".".join(AUDIT_CALLBACK.split(".")[:-1])),AUDIT_CALLBACK.split(".")[-1]
+ )
+ logger.log_sec("Auditing module online.")
+ logger.log_sec(
+ "Audit record User input: {}, output: {}.\n"
+ "Audit sparse recording: {}, Log callback: {}".format(
+ AUDIT_IN,AUDIT_OUT,AUDIT_ALLOW_SPARSE,AUDIT_CALLBACK
+ )
+ )
+ exceptExceptionase:
+ logger.log_err("Failed to activate Auditing module. %s"%e)
+
+
+
[docs]classAuditedServerSession(ServerSession):
+ """
+ This particular implementation parses all server inputs and/or outputs and
+ passes a dict containing the parsed metadata to a callback method of your
+ creation. This is useful for recording player activity where necessary for
+ security auditing, usage analysis or post-incident forensic discovery.
+
+ *** WARNING ***
+ All strings are recorded and stored in plaintext. This includes those strings
+ which might contain sensitive data (create, connect, @password). These commands
+ have their arguments masked by default, but you must mask or mask any
+ custom commands of your own that handle sensitive information.
+
+ See README.md for installation/configuration instructions.
+ """
+
+
[docs]defaudit(self,**kwargs):
+ """
+ Extracts messages and system data from a Session object upon message
+ send or receive.
+
+ Keyword Args:
+ src (str): Source of data; 'client' or 'server'. Indicates direction.
+ text (str or list): Client sends messages to server in the form of
+ lists. Server sends messages to client as string.
+
+ Returns:
+ log (dict): Dictionary object containing parsed system and user data
+ related to this message.
+
+ """
+ # Get time at start of processing
+ time_obj=timezone.now()
+ time_str=str(time_obj)
+
+ session=self
+ src=kwargs.pop("src","?")
+ bytecount=0
+
+ # Do not log empty lines
+ ifnotkwargs:
+ return{}
+
+ # Get current session's IP address
+ client_ip=session.address
+
+ # Capture Account name and dbref together
+ account=session.get_account()
+ account_token=""
+ ifaccount:
+ account_token="%s%s"%(account.key,account.dbref)
+
+ # Capture Character name and dbref together
+ char=session.get_puppet()
+ char_token=""
+ ifchar:
+ char_token="%s%s"%(char.key,char.dbref)
+
+ # Capture Room name and dbref together
+ room=None
+ room_token=""
+ ifchar:
+ room=char.location
+ room_token="%s%s"%(room.key,room.dbref)
+
+ # Try to compile an input/output string
+ defdrill(obj,bucket):
+ ifisinstance(obj,dict):
+ returnbucket
+ elifutils.is_iter(obj):
+ forsub_objinobj:
+ bucket.extend(drill(sub_obj,[]))
+ else:
+ bucket.append(obj)
+ returnbucket
+
+ text=kwargs.pop("text","")
+ ifutils.is_iter(text):
+ text="|".join(drill(text,[]))
+
+ # Mask any PII in message, where possible
+ bytecount=len(text.encode("utf-8"))
+ text=self.mask(text)
+
+ # Compile the IP, Account, Character, Room, and the message.
+ log={
+ "time":time_str,
+ "hostname":socket.getfqdn(),
+ "application":"%s"%ev_settings.SERVERNAME,
+ "version":get_evennia_version(),
+ "pid":os.getpid(),
+ "direction":"SND"ifsrc=="server"else"RCV",
+ "protocol":self.protocol_key,
+ "ip":client_ip,
+ "session":"session#%s"%self.sessid,
+ "account":account_token,
+ "character":char_token,
+ "room":room_token,
+ "text":text.strip(),
+ "bytes":bytecount,
+ "data":kwargs,
+ "objects":{
+ "time":time_obj,
+ "session":self,
+ "account":account,
+ "character":char,
+ "room":room,
+ },
+ }
+
+ # Remove any keys with blank values
+ ifAUDIT_ALLOW_SPARSEisFalse:
+ log["data"]={k:vfork,vinlog["data"].items()ifv}
+ log["objects"]={k:vfork,vinlog["objects"].items()ifv}
+ log={k:vfork,vinlog.items()ifv}
+
+ returnlog
+
+
[docs]defmask(self,msg):
+ """
+ Masks potentially sensitive user information within messages before
+ writing to log. Recording cleartext password attempts is bad policy.
+
+ Args:
+ msg (str): Raw text string sent from client <-> server
+
+ Returns:
+ msg (str): Text string with sensitive information masked out.
+
+ """
+ # Check to see if the command is embedded within server output
+ _msg=msg
+ is_embedded=False
+ match=re.match(".*Command.*'(.+)'.*is not available.*",msg,flags=re.IGNORECASE)
+ ifmatch:
+ msg=match.group(1).replace("\\","")
+ submsg=msg
+ is_embedded=True
+
+ formaskinAUDIT_MASKS:
+ forcommand,regexinmask.items():
+ try:
+ match=re.match(regex,msg,flags=re.IGNORECASE)
+ exceptExceptionase:
+ logger.log_err(regex)
+ logger.log_err(e)
+ continue
+
+ ifmatch:
+ term=match.group("secret")
+ masked=re.sub(term,"*"*len(term.zfill(8)),msg)
+
+ ifis_embedded:
+ msg=re.sub(
+ submsg,"%s <Masked: %s>"%(masked,command),_msg,flags=re.IGNORECASE
+ )
+ else:
+ msg=masked
+
+ returnmsg
+
+ return_msg
+
+
[docs]defdata_out(self,**kwargs):
+ """
+ Generic hook for sending data out through the protocol.
+
+ Keyword Args:
+ kwargs (any): Other data to the protocol.
+
+ """
+ ifAUDIT_CALLBACKandAUDIT_OUT:
+ try:
+ log=self.audit(src="server",**kwargs)
+ iflog:
+ AUDIT_CALLBACK(log)
+ exceptExceptionase:
+ logger.log_err(e)
+
+ super(AuditedServerSession,self).data_out(**kwargs)
+
+
[docs]defdata_in(self,**kwargs):
+ """
+ Hook for protocols to send incoming data to the engine.
+
+ Keyword Args:
+ kwargs (any): Other data from the protocol.
+
+ """
+ ifAUDIT_CALLBACKandAUDIT_IN:
+ try:
+ log=self.audit(src="client",**kwargs)
+ iflog:
+ AUDIT_CALLBACK(log)
+ exceptExceptionase:
+ logger.log_err(e)
+
+ super(AuditedServerSession,self).data_in(**kwargs)
Source code for evennia.contrib.security.auditing.tests
+"""
+Module containing the test cases for the Audit system.
+"""
+
+fromanythingimportAnything
+fromdjango.testimportoverride_settings
+fromdjango.confimportsettings
+fromevennia.utils.test_resourcesimportEvenniaTest
+importre
+
+# Configure session auditing settings - TODO: This is bad practice that leaks over to other tests
+settings.AUDIT_CALLBACK="evennia.security.contrib.auditing.outputs.to_syslog"
+settings.AUDIT_IN=True
+settings.AUDIT_OUT=True
+settings.AUDIT_ALLOW_SPARSE=True
+
+# Configure settings to use custom session - TODO: This is bad practice, changing global settings
+settings.SERVER_SESSION_CLASS="evennia.contrib.security.auditing.server.AuditedServerSession"
+
+
+
[docs]deftest_mask(self):
+ """
+ Make sure the 'mask' function is properly masking potentially sensitive
+ information from strings.
+ """
+ safe_cmds=(
+ "/say hello to my little friend",
+ "@ccreate channel = for channeling",
+ "@create/drop some stuff",
+ "@create rock",
+ "@create a pretty shirt : evennia.contrib.clothing.Clothing",
+ "@charcreate johnnyefhiwuhefwhef",
+ 'Command "@logout" is not available. Maybe you meant "@color" or "@cboot"?',
+ '/me says, "what is the password?"',
+ "say the password is plugh",
+ # Unfortunately given the syntax, there is no way to discern the
+ # latter of these as sensitive
+ "@create pretty sunset""@create johnny password123",
+ '{"text": "Command \'do stuff\' is not available. Type "help" for help."}',
+ )
+
+ forcmdinsafe_cmds:
+ self.assertEqual(self.session.mask(cmd),cmd)
+
+ unsafe_cmds=(
+ (
+ "something - new password set to 'asdfghjk'.",
+ "something - new password set to '********'.",
+ ),
+ (
+ "someone has changed your password to 'something'.",
+ "someone has changed your password to '*********'.",
+ ),
+ ("connect johnny password123","connect johnny ***********"),
+ ("concnct johnny password123","concnct johnny ***********"),
+ ("concnct johnnypassword123","concnct *****************"),
+ ('connect "johnny five" "password 123"','connect "johnny five" **************'),
+ ('connect johnny "password 123"',"connect johnny **************"),
+ ("create johnny password123","create johnny ***********"),
+ ("@password password1234 = password2345","@password ***************************"),
+ ("@password password1234 password2345","@password *************************"),
+ ("@passwd password1234 = password2345","@passwd ***************************"),
+ ("@userpassword johnny = password234","@userpassword johnny = ***********"),
+ ("craete johnnypassword123","craete *****************"),
+ (
+ "Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?",
+ "Command 'conncect ******** ********' is not available. Maybe you meant \"@encode\"?",
+ ),
+ (
+ "{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}",
+ "{'text': u'Command \\'conncect jsis ********\\' is not available. Type \"help\" for help.'}",
+ ),
+ )
+
+ forindex,(unsafe,safe)inenumerate(unsafe_cmds):
+ self.assertEqual(re.sub(" <Masked: .+>","",self.session.mask(unsafe)).strip(),safe)
+
+ # Make sure scrubbing is not being abused to evade monitoring
+ secrets=[
+ "say password password password; ive got a secret that i cant explain",
+ "whisper johnny = password\n let's lynch the landlord",
+ "say connect johnny password1234|the secret life of arabia",
+ "@password eval(\"__import__('os').system('clear')\", {'__builtins__':{}})",
+ ]
+ forsecretinsecrets:
+ self.assertEqual(self.session.mask(secret),secret)
+
+
[docs]deftest_audit(self):
+ """
+ Make sure the 'audit' function is returning a dictionary based on values
+ parsed from the Session object.
+ """
+ log=self.session.audit(src="client",text=[["hello"]])
+ obj={
+ k:vfork,vinlog.items()ifkin("direction","protocol","application","text")
+ }
+ self.assertEqual(
+ obj,
+ {
+ "direction":"RCV",
+ "protocol":"telnet",
+ "application":Anything,# this will change if running tests from the game dir
+ "text":"hello",
+ },
+ )
+
+ # Make sure OOB data is being recorded
+ log=self.session.audit(
+ src="client",text="connect johnny password123",prompt="hp=20|st=10|ma=15",pane=2
+ )
+ self.assertEqual(log["text"],"connect johnny ***********")
+ self.assertEqual(log["data"]["prompt"],"hp=20|st=10|ma=15")
+ self.assertEqual(log["data"]["pane"],2)
+"""
+SimpleDoor
+
+Contribution - Griatch 2016
+
+A simple two-way exit that represents a door that can be opened and
+closed. Can easily be expanded from to make it lockable, destroyable
+etc. Note that the simpledoor is based on Evennia locks, so it will
+not work for a superuser (which bypasses all locks) - the superuser
+will always appear to be able to close/open the door over and over
+without the locks stopping you. To use the door, use `@quell` or a
+non-superuser account.
+
+Installation:
+
+ Import this module in mygame/commands/default_cmdsets and add
+ the CmdOpen and CmdOpenCloseDoor commands to the CharacterCmdSet;
+ then reload the server.
+
+To try it out, `@dig` a new room and then use the (overloaded) `@open`
+commmand to open a new doorway to it like this:
+
+ @open doorway:contrib.simpledoor.SimpleDoor = otherroom
+
+You can then use `open doorway' and `close doorway` to change the open
+state. If you are not superuser (`@quell` yourself) you'll find you
+cannot pass through either side of the door once it's closed from the
+other side.
+
+"""
+
+fromevenniaimportDefaultExit,default_cmds
+fromevennia.utils.utilsimportinherits_from
+
+
+
[docs]classSimpleDoor(DefaultExit):
+ """
+ A two-way exit "door" with some methods for affecting both "sides"
+ of the door at the same time. For example, set a lock on either of the two
+ sides using `exitname.setlock("traverse:false())`
+
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called the very first time the door is created.
+
+ """
+ self.db.return_exit=None
+
+
[docs]defsetlock(self,lockstring):
+ """
+ Sets identical locks on both sides of the door.
+
+ Args:
+ lockstring (str): A lockstring, like `"traverse:true()"`.
+
+ """
+ self.locks.add(lockstring)
+ self.db.return_exit.locks.add(lockstring)
+
+
[docs]defsetdesc(self,description):
+ """
+ Sets identical descs on both sides of the door.
+
+ Args:
+ setdesc (str): A description.
+
+ """
+ self.db.desc=description
+ self.db.return_exit.db.desc=description
+
+
[docs]defdelete(self):
+ """
+ Deletes both sides of the door.
+
+ """
+ # we have to be careful to avoid a delete-loop.
+ ifself.db.return_exit:
+ super().delete()
+ super().delete()
+ returnTrue
+
+
[docs]defat_failed_traverse(self,traversing_object):
+ """
+ Called when door traverse: lock fails.
+
+ Args:
+ traversing_object (Typeclassed entity): The object
+ attempting the traversal.
+
+ """
+ traversing_object.msg("%s is closed."%self.key)
+
+
+
[docs]classCmdOpen(default_cmds.CmdOpen):
+ __doc__=default_cmds.CmdOpen.__doc__
+ # overloading parts of the default CmdOpen command to support doors.
+
+
[docs]defcreate_exit(self,exit_name,location,destination,exit_aliases=None,typeclass=None):
+ """
+ Simple wrapper for the default CmdOpen.create_exit
+ """
+ # create a new exit as normal
+ new_exit=super().create_exit(
+ exit_name,location,destination,exit_aliases=exit_aliases,typeclass=typeclass
+ )
+ ifhasattr(self,"return_exit_already_created"):
+ # we don't create a return exit if it was already created (because
+ # we created a door)
+ delself.return_exit_already_created
+ returnnew_exit
+ ifinherits_from(new_exit,SimpleDoor):
+ # a door - create its counterpart and make sure to turn off the default
+ # return-exit creation of CmdOpen
+ self.caller.msg(
+ "Note: A door-type exit was created - ignored eventual custom return-exit type."
+ )
+ self.return_exit_already_created=True
+ back_exit=self.create_exit(
+ exit_name,destination,location,exit_aliases=exit_aliases,typeclass=typeclass
+ )
+ new_exit.db.return_exit=back_exit
+ back_exit.db.return_exit=new_exit
+ returnnew_exit
+
+
+# A simple example of a command making use of the door exit class'
+# functionality. One could easily expand it with functionality to
+# operate on other types of open-able objects as needed.
+
+
+
[docs]classCmdOpenCloseDoor(default_cmds.MuxCommand):
+ """
+ Open and close a door
+
+ Usage:
+ open <door>
+ close <door>
+
+ """
+
+ key="open"
+ aliases=["close"]
+ locks="cmd:all()"
+ help_category="General"
+
+
[docs]deffunc(self):
+ "implement the door functionality"
+ ifnotself.args:
+ self.caller.msg("Usage: open||close <door>")
+ return
+
+ door=self.caller.search(self.args)
+ ifnotdoor:
+ return
+ ifnotinherits_from(door,SimpleDoor):
+ self.caller.msg("This is not a door.")
+ return
+
+ ifself.cmdstring=="open":
+ ifdoor.locks.check(self.caller,"traverse"):
+ self.caller.msg("%s is already open."%door.key)
+ else:
+ door.setlock("traverse:true()")
+ self.caller.msg("You open %s."%door.key)
+ else:# close
+ ifnotdoor.locks.check(self.caller,"traverse"):
+ self.caller.msg("%s is already closed."%door.key)
+ else:
+ door.setlock("traverse:false()")
+ self.caller.msg("You close %s."%door.key)
+"""
+Slow Exit typeclass
+
+Contribution - Griatch 2014
+
+
+This is an example of an Exit-type that delays its traversal.This
+simulates slow movement, common in many different types of games. The
+contrib also contains two commands, CmdSetSpeed and CmdStop for changing
+the movement speed and abort an ongoing traversal, respectively.
+
+To try out an exit of this type, you could connect two existing rooms
+using something like this:
+
+@open north:contrib.slow_exit.SlowExit = <destination>
+
+
+Installation:
+
+To make this your new default exit, modify mygame/typeclasses/exits.py
+to import this module and change the default Exit class to inherit
+from SlowExit instead.
+
+To get the ability to change your speed and abort your movement,
+simply import and add CmdSetSpeed and CmdStop from this module to your
+default cmdset (see tutorials on how to do this if you are unsure).
+
+Notes:
+
+This implementation is efficient but not persistent; so incomplete
+movement will be lost in a server reload. This is acceptable for most
+game types - to simulate longer travel times (more than the couple of
+seconds assumed here), a more persistent variant using Scripts or the
+TickerHandler might be better.
+
+"""
+
+fromevenniaimportDefaultExit,utils,Command
+
+MOVE_DELAY={"stroll":6,"walk":4,"run":2,"sprint":1}
+
+
+
[docs]classSlowExit(DefaultExit):
+ """
+ This overloads the way moving happens.
+ """
+
+
[docs]defat_traverse(self,traversing_object,target_location):
+ """
+ Implements the actual traversal, using utils.delay to delay the move_to.
+ """
+
+ # if the traverser has an Attribute move_speed, use that,
+ # otherwise default to "walk" speed
+ move_speed=traversing_object.db.move_speedor"walk"
+ move_delay=MOVE_DELAY.get(move_speed,4)
+
+ defmove_callback():
+ "This callback will be called by utils.delay after move_delay seconds."
+ source_location=traversing_object.location
+ iftraversing_object.move_to(target_location):
+ self.at_after_traverse(traversing_object,source_location)
+ else:
+ ifself.db.err_traverse:
+ # if exit has a better error message, let's use it.
+ self.caller.msg(self.db.err_traverse)
+ else:
+ # No shorthand error message. Call hook.
+ self.at_failed_traverse(traversing_object)
+
+ traversing_object.msg("You start moving %s at a %s."%(self.key,move_speed))
+ # create a delayed movement
+ t=utils.delay(move_delay,move_callback)
+ # we store the deferred on the character, this will allow us
+ # to abort the movement. We must use an ndb here since
+ # deferreds cannot be pickled.
+ traversing_object.ndb.currently_moving=t
[docs]classCmdSetSpeed(Command):
+ """
+ set your movement speed
+
+ Usage:
+ setspeed stroll|walk|run|sprint
+
+ This will set your movement speed, determining how long time
+ it takes to traverse exits. If no speed is set, 'walk' speed
+ is assumed.
+ """
+
+ key="setspeed"
+
+
[docs]deffunc(self):
+ """
+ Simply sets an Attribute used by the SlowExit above.
+ """
+ speed=self.args.lower().strip()
+ ifspeednotinSPEED_DESCS:
+ self.caller.msg("Usage: setspeed stroll||walk||run||sprint")
+ elifself.caller.db.move_speed==speed:
+ self.caller.msg("You are already %s."%SPEED_DESCS[speed])
+ else:
+ self.caller.db.move_speed=speed
+ self.caller.msg("You are now %s."%SPEED_DESCS[speed])
+
+
+#
+# stop moving - command
+#
+
+
+
[docs]classCmdStop(Command):
+ """
+ stop moving
+
+ Usage:
+ stop
+
+ Stops the current movement, if any.
+ """
+
+ key="stop"
+
+
[docs]deffunc(self):
+ """
+ This is a very simple command, using the
+ stored deferred from the exit traversal above.
+ """
+ currently_moving=self.caller.ndb.currently_moving
+ ifcurrently_movingandnotcurrently_moving.called:
+ currently_moving.cancel()
+ self.caller.msg("You stop moving.")
+ forobserverinself.caller.location.contents_get(self.caller):
+ observer.msg("%s stops."%self.caller.get_display_name(observer))
+ else:
+ self.caller.msg("You are not moving.")
+"""
+Evennia Talkative NPC
+
+Contribution - Griatch 2011, grungies1138, 2016
+
+This is a static NPC object capable of holding a simple menu-driven
+conversation. It's just meant as an example. Create it by creating an
+object of typeclass contrib.talking_npc.TalkingNPC, For example using
+@create:
+
+ @create/drop John : contrib.talking_npc.TalkingNPC
+
+Walk up to it and give the talk command to strike up a conversation.
+If there are many talkative npcs in the same room you will get to
+choose which one's talk command to call (Evennia handles this
+automatically). This use of EvMenu is very simplistic; See EvMenu for
+a lot more complex possibilities.
+
+
+"""
+
+fromevenniaimportDefaultObject,CmdSet,default_cmds
+fromevennia.utils.evmenuimportEvMenu
+
+
+# Menu implementing the dialogue tree
+
+
+
[docs]defmenu_start_node(caller):
+ text="'Hello there, how can I help you?'"
+
+ options=(
+ {"desc":"Hey, do you know what this 'Evennia' thing is all about?","goto":"info1"},
+ {"desc":"What's your name, little NPC?","goto":"info2"},
+ )
+
+ returntext,options
+
+
+
[docs]definfo1(caller):
+ text="'Oh, Evennia is where you are right now! Don't you feel the power?'"
+
+ options=(
+ {"desc":"Sure, *I* do, not sure how you do though. You are just an NPC.","goto":"info3"},
+ {"desc":"Sure I do. What's yer name, NPC?","goto":"info2"},
+ {"desc":"Ok, bye for now then.","goto":"END"},
+ )
+
+ returntext,options
+
+
+
[docs]definfo2(caller):
+ text="'My name is not really important ... I'm just an NPC after all.'"
+
+ options=(
+ {"desc":"I didn't really want to know it anyhow.","goto":"info3"},
+ {"desc":"Okay then, so what's this 'Evennia' thing about?","goto":"info1"},
+ )
+
+ returntext,options
+
+
+
[docs]definfo3(caller):
+ text="'Well ... I'm sort of busy so, have to go. NPC business. Important stuff. You wouldn't understand.'"
+
+ options=(
+ {"desc":"Oookay ... I won't keep you. Bye.","goto":"END"},
+ {"desc":"Wait, why don't you tell me your name first?","goto":"info2"},
+ )
+
+ returntext,options
+
+
+#
+# The talk command (sits on the NPC)
+#
+
+
+
[docs]classCmdTalk(default_cmds.MuxCommand):
+ """
+ Talks to an npc
+
+ Usage:
+ talk
+
+ This command is only available if a talkative non-player-character
+ (NPC) is actually present. It will strike up a conversation with
+ that NPC and give you options on what to talk about.
+ """
+
+ key="talk"
+ locks="cmd:all()"
+ help_category="General"
+
+
[docs]deffunc(self):
+ "Implements the command."
+
+ # self.obj is the NPC this is defined on
+ self.caller.msg("(You walk up and talk to %s.)"%self.obj.key)
+
+ # Initiate the menu. Change this if you are putting this on
+ # some other custom NPC class.
+ EvMenu(self.caller,"evennia.contrib.talking_npc",startnode="menu_start_node")
+
+
+
[docs]classTalkingCmdSet(CmdSet):
+ "Stores the talk command."
+ key="talkingcmdset"
+
+
[docs]defat_cmdset_creation(self):
+ "populates the cmdset"
+ self.add(CmdTalk())
+
+
+
[docs]classTalkingNPC(DefaultObject):
+ """
+ This implements a simple Object using the talk command and using
+ the conversation defined above.
+ """
+
+
[docs]defat_object_creation(self):
+ "This is called when object is first created."
+ self.db.desc="This is a talkative NPC."
+ # assign the talk command to npc
+ self.cmdset.add_default(TalkingCmdSet,permanent=True)
+"""
+Easy menu selection tree
+
+Contrib - Tim Ashley Jenkins 2017
+
+This module allows you to create and initialize an entire branching EvMenu
+instance with nothing but a multi-line string passed to one function.
+
+EvMenu is incredibly powerful and flexible, but using it for simple menus
+can often be fairly cumbersome - a simple menu that can branch into five
+categories would require six nodes, each with options represented as a list
+of dictionaries.
+
+This module provides a function, init_tree_selection, which acts as a frontend
+for EvMenu, dynamically sourcing the options from a multi-line string you provide.
+For example, if you define a string as such:
+
+ TEST_MENU = '''Foo
+ Bar
+ Baz
+ Qux'''
+
+And then use TEST_MENU as the 'treestr' source when you call init_tree_selection
+on a player:
+
+ init_tree_selection(TEST_MENU, caller, callback)
+
+The player will be presented with an EvMenu, like so:
+
+ ___________________________
+
+ Make your selection:
+ ___________________________
+
+ Foo
+ Bar
+ Baz
+ Qux
+
+Making a selection will pass the selection's key to the specified callback as a
+string along with the caller, as well as the index of the selection (the line number
+on the source string) along with the source string for the tree itself.
+
+In addition to specifying selections on the menu, you can also specify categories.
+Categories are indicated by putting options below it preceded with a '-' character.
+If a selection is a category, then choosing it will bring up a new menu node, prompting
+the player to select between those options, or to go back to the previous menu. In
+addition, categories are marked by default with a '[+]' at the end of their key. Both
+this marker and the option to go back can be disabled.
+
+Categories can be nested in other categories as well - just go another '-' deeper. You
+can do this as many times as you like. There's no hard limit to the number of
+categories you can go down.
+
+For example, let's add some more options to our menu, turning 'Bar' into a category.
+
+ TEST_MENU = '''Foo
+ Bar
+ -You've got to know
+ --When to hold em
+ --When to fold em
+ --When to walk away
+ Baz
+ Qux'''
+
+Now when we call the menu, we can see that 'Bar' has become a category instead of a
+selectable option.
+
+ _______________________________
+
+ Make your selection:
+ _______________________________
+
+ Foo
+ Bar [+]
+ Baz
+ Qux
+
+Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it.
+
+ ________________________________________________________________
+
+ Bar
+ ________________________________________________________________
+
+ You've got to know [+]
+ << Go Back: Return to the previous menu.
+
+Just the one option, which is a category itself, and the option to go back, which will
+take us back to the previous menu. Let's select 'You've got to know'.
+
+ ________________________________________________________________
+
+ You've got to know
+ ________________________________________________________________
+
+ When to hold em
+ When to fold em
+ When to walk away
+ << Go Back: Return to the previous menu.
+
+Now we see the three options listed under it, too. We can select one of them or use 'Go
+Back' to return to the 'Bar' menu we were just at before. It's very simple to make a
+branching tree of selections!
+
+One last thing - you can set the descriptions for the various options simply by adding a
+':' character followed by the description to the option's line. For example, let's add a
+description to 'Baz' in our menu:
+
+ TEST_MENU = '''Foo
+ Bar
+ -You've got to know
+ --When to hold em
+ --When to fold em
+ --When to walk away
+ Baz: Look at this one: the best option.
+ Qux'''
+
+Now we see that the Baz option has a description attached that's separate from its key:
+
+ _______________________________________________________________
+
+ Make your selection:
+ _______________________________________________________________
+
+ Foo
+ Bar [+]
+ Baz: Look at this one: the best option.
+ Qux
+
+Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call
+your specified callback with the selection, like so:
+
+ callback(caller, TEST_MENU, 0, "Foo")
+
+The index of the selection is given along with a string containing the selection's key.
+That way, if you have two selections in the menu with the same key, you can still
+differentiate between them.
+
+And that's all there is to it! For simple branching-tree selections, using this system is
+much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic
+options much easier - since the source of the menu tree is just a string, you could easily
+generate that string procedurally before passing it to the init_tree_selection function.
+For example, if a player casts a spell or does an attack without specifying a target, instead
+of giving them an error, you could present them with a list of valid targets to select by
+generating a multi-line string of targets and passing it to init_tree_selection, with the
+callable performing the maneuver once a selection is made.
+
+This selection system only works for simple branching trees - doing anything really complicated
+like jumping between categories or prompting for arbitrary input would still require a full
+EvMenu implementation. For simple selections, however, I'm sure you will find using this function
+to be much easier!
+
+Included in this module is a sample menu and function which will let a player change the color
+of their name - feel free to mess with it to get a feel for how this system works by importing
+this module in your game's default_cmdsets.py module and adding CmdNameColor to your default
+character's command set.
+"""
+
+fromevennia.utilsimportevmenu
+fromevennia.utils.loggerimportlog_trace
+fromevenniaimportCommand
+
+
+
[docs]definit_tree_selection(
+ treestr,
+ caller,
+ callback,
+ index=None,
+ mark_category=True,
+ go_back=True,
+ cmd_on_exit="look",
+ start_text="Make your selection:",
+):
+ """
+ Prompts a player to select an option from a menu tree given as a multi-line string.
+
+ Args:
+ treestr (str): Multi-lne string representing menu options
+ caller (obj): Player to initialize the menu for
+ callback (callable): Function to run when a selection is made. Must take 4 args:
+ caller (obj): Caller given above
+ treestr (str): Menu tree string given above
+ index (int): Index of final selection
+ selection (str): Key of final selection
+
+ Options:
+ index (int or None): Index to start the menu at, or None for top level
+ mark_category (bool): If True, marks categories with a [+] symbol in the menu
+ go_back (bool): If True, present an option to go back to previous categories
+ start_text (str): Text to display at the top level of the menu
+ cmd_on_exit(str): Command to enter when the menu exits - 'look' by default
+
+
+ Notes:
+ This function will initialize an instance of EvMenu with options generated
+ dynamically from the source string, and passes the menu user's selection to
+ a function of your choosing. The EvMenu is made of a single, repeating node,
+ which will call itself over and over at different levels of the menu tree as
+ categories are selected.
+
+ Once a non-category selection is made, the user's selection will be passed to
+ the given callable, both as a string and as an index number. The index is given
+ to ensure every selection has a unique identifier, so that selections with the
+ same key in different categories can be distinguished between.
+
+ The menus called by this function are not persistent and cannot perform
+ complicated tasks like prompt for arbitrary input or jump multiple category
+ levels at once - you'll have to use EvMenu itself if you want to take full
+ advantage of its features.
+ """
+
+ # Pass kwargs to store data needed in the menu
+ kwargs={
+ "index":index,
+ "mark_category":mark_category,
+ "go_back":go_back,
+ "treestr":treestr,
+ "callback":callback,
+ "start_text":start_text,
+ }
+
+ # Initialize menu of selections
+ evmenu.EvMenu(
+ caller,
+ "evennia.contrib.tree_select",
+ startnode="menunode_treeselect",
+ startnode_input=None,
+ cmd_on_exit=cmd_on_exit,
+ **kwargs,
+ )
+
+
+
[docs]defdashcount(entry):
+ """
+ Counts the number of dashes at the beginning of a string. This
+ is needed to determine the depth of options in categories.
+
+ Args:
+ entry (str): String to count the dashes at the start of
+
+ Returns:
+ dashes (int): Number of dashes at the start
+ """
+ dashes=0
+ forcharinentry:
+ ifchar=="-":
+ dashes+=1
+ else:
+ returndashes
+ returndashes
+
+
+
[docs]defis_category(treestr,index):
+ """
+ Determines whether an option in a tree string is a category by
+ whether or not there are additional options below it.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ index (int): Which line of the string to test
+
+ Returns:
+ is_category (bool): Whether the option is a category
+ """
+ opt_list=treestr.split("\n")
+ # Not a category if it's the last one in the list
+ ifindex==len(opt_list)-1:
+ returnFalse
+ # Not a category if next option is not one level deeper
+ returnnotbool(dashcount(opt_list[index+1])!=dashcount(opt_list[index])+1)
+
+
+
[docs]defparse_opts(treestr,category_index=None):
+ """
+ Parses a tree string and given index into a list of options. If
+ category_index is none, returns all the options at the top level of
+ the menu. If category_index corresponds to a category, returns a list
+ of options under that category. If category_index corresponds to
+ an option that is not a category, it's a selection and returns True.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ category_index (int): Index of category or None for top level
+
+ Returns:
+ kept_opts (list or True): Either a list of options in the selected
+ category or True if a selection was made
+ """
+ dash_depth=0
+ opt_list=treestr.split("\n")
+ kept_opts=[]
+
+ # If a category index is given
+ ifcategory_index!=None:
+ # If given index is not a category, it's a selection - return True.
+ ifnotis_category(treestr,category_index):
+ returnTrue
+ # Otherwise, change the dash depth to match the new category.
+ dash_depth=dashcount(opt_list[category_index])+1
+ # Delete everything before the category index
+ opt_list=opt_list[category_index+1:]
+
+ # Keep every option (referenced by index) at the appropriate depth
+ cur_index=0
+ foroptioninopt_list:
+ ifdashcount(option)==dash_depth:
+ ifcategory_index==None:
+ kept_opts.append((cur_index,option[dash_depth:]))
+ else:
+ kept_opts.append((cur_index+category_index+1,option[dash_depth:]))
+ # Exits the loop if leaving a category
+ ifdashcount(option)<dash_depth:
+ returnkept_opts
+ cur_index+=1
+ returnkept_opts
+
+
+
[docs]defindex_to_selection(treestr,index,desc=False):
+ """
+ Given a menu tree string and an index, returns the corresponding selection's
+ name as a string. If 'desc' is set to True, will return the selection's
+ description as a string instead.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ index (int): Index to convert to selection key or description
+
+ Options:
+ desc (bool): If true, returns description instead of key
+
+ Returns:
+ selection (str): Selection key or description if 'desc' is set
+ """
+ opt_list=treestr.split("\n")
+ # Fetch the given line
+ selection=opt_list[index]
+ # Strip out the dashes at the start
+ selection=selection[dashcount(selection):]
+ # Separate out description, if any
+ if":"inselection:
+ # Split string into key and description
+ selection=selection.split(":",1)
+ selection[1]=selection[1].strip(" ")
+ else:
+ # If no description given, set description to None
+ selection=[selection,None]
+ ifnotdesc:
+ returnselection[0]
+ else:
+ returnselection[1]
+
+
+
[docs]defgo_up_one_category(treestr,index):
+ """
+ Given a menu tree string and an index, returns the category that the given option
+ belongs to. Used for the 'go back' option.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ index (int): Index to determine the parent category of
+
+ Returns:
+ parent_category (int): Index of parent category
+ """
+ opt_list=treestr.split("\n")
+ # Get the number of dashes deep the given index is
+ dash_level=dashcount(opt_list[index])
+ # Delete everything after the current index
+ opt_list=opt_list[:index+1]
+
+ # If there's no dash, return 'None' to return to base menu
+ ifdash_level==0:
+ returnNone
+ current_index=index
+ # Go up through each option until we find one that's one category above
+ forselectioninreversed(opt_list):
+ ifdashcount(selection)==dash_level-1:
+ returncurrent_index
+ current_index-=1
+
+
+
[docs]defoptlist_to_menuoptions(treestr,optlist,index,mark_category,go_back):
+ """
+ Takes a list of options processed by parse_opts and turns it into
+ a list/dictionary of menu options for use in menunode_treeselect.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ optlist (list): List of options to convert to EvMenu's option format
+ index (int): Index of current category
+ mark_category (bool): Whether or not to mark categories with [+]
+ go_back (bool): Whether or not to add an option to go back in the menu
+
+ Returns:
+ menuoptions (list of dicts): List of menu options formatted for use
+ in EvMenu, each passing a different "newindex" kwarg that changes
+ the menu level or makes a selection
+ """
+
+ menuoptions=[]
+ cur_index=0
+ foroptioninoptlist:
+ index_to_add=optlist[cur_index][0]
+ menuitem={}
+ keystr=index_to_selection(treestr,index_to_add)
+ ifmark_categoryandis_category(treestr,index_to_add):
+ # Add the [+] to the key if marking categories, and the key by itself as an alias
+ menuitem["key"]=[keystr+" [+]",keystr]
+ else:
+ menuitem["key"]=keystr
+ # Get the option's description
+ desc=index_to_selection(treestr,index_to_add,desc=True)
+ ifdesc:
+ menuitem["desc"]=desc
+ # Passing 'newindex' as a kwarg to the node is how we move through the menu!
+ menuitem["goto"]=["menunode_treeselect",{"newindex":index_to_add}]
+ menuoptions.append(menuitem)
+ cur_index+=1
+ # Add option to go back, if needed
+ ifindex!=Noneandgo_back==True:
+ gobackitem={
+ "key":["<< Go Back","go back","back"],
+ "desc":"Return to the previous menu.",
+ "goto":["menunode_treeselect",{"newindex":go_up_one_category(treestr,index)}],
+ }
+ menuoptions.append(gobackitem)
+ returnmenuoptions
+
+
+
[docs]defmenunode_treeselect(caller,raw_string,**kwargs):
+ """
+ This is the repeating menu node that handles the tree selection.
+ """
+
+ # If 'newindex' is in the kwargs, change the stored index.
+ if"newindex"inkwargs:
+ caller.ndb._menutree.index=kwargs["newindex"]
+
+ # Retrieve menu info
+ index=caller.ndb._menutree.index
+ mark_category=caller.ndb._menutree.mark_category
+ go_back=caller.ndb._menutree.go_back
+ treestr=caller.ndb._menutree.treestr
+ callback=caller.ndb._menutree.callback
+ start_text=caller.ndb._menutree.start_text
+
+ # List of options if index is 'None' or category, or 'True' if a selection
+ optlist=parse_opts(treestr,category_index=index)
+
+ # If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu.
+ ifoptlist==True:
+ selection=index_to_selection(treestr,index)
+ try:
+ callback(caller,treestr,index,selection)
+ exceptException:
+ log_trace("Error in tree selection callback.")
+
+ # Returning None, None ends the menu.
+ returnNone,None
+
+ # Otherwise, convert optlist to a list of menu options.
+ else:
+ options=optlist_to_menuoptions(treestr,optlist,index,mark_category,go_back)
+ ifindex==None:
+ # Use start_text for the menu text on the top level
+ text=start_text
+ else:
+ # Use the category name and description (if any) as the menu text
+ ifindex_to_selection(treestr,index,desc=True)!=None:
+ text=(
+ "|w"
+ +index_to_selection(treestr,index)
+ +"|n: "
+ +index_to_selection(treestr,index,desc=True)
+ )
+ else:
+ text="|w"+index_to_selection(treestr,index)+"|n"
+ returntext,options
+
+
+# The rest of this module is for the example menu and command! It'll change the color of your name.
+
+"""
+Here's an example string that you can initialize a menu from. Note the dashes at
+the beginning of each line - that's how menu option depth and hierarchy is determined.
+"""
+
+NAMECOLOR_MENU="""Set name color: Choose a color for your name!
+-Red shades: Various shades of |511red|n
+--Red: |511Set your name to Red|n
+--Pink: |533Set your name to Pink|n
+--Maroon: |301Set your name to Maroon|n
+-Orange shades: Various shades of |531orange|n
+--Orange: |531Set your name to Orange|n
+--Brown: |321Set your name to Brown|n
+--Sienna: |420Set your name to Sienna|n
+-Yellow shades: Various shades of |551yellow|n
+--Yellow: |551Set your name to Yellow|n
+--Gold: |540Set your name to Gold|n
+--Dandelion: |553Set your name to Dandelion|n
+-Green shades: Various shades of |141green|n
+--Green: |141Set your name to Green|n
+--Lime: |350Set your name to Lime|n
+--Forest: |032Set your name to Forest|n
+-Blue shades: Various shades of |115blue|n
+--Blue: |115Set your name to Blue|n
+--Cyan: |155Set your name to Cyan|n
+--Navy: |113Set your name to Navy|n
+-Purple shades: Various shades of |415purple|n
+--Purple: |415Set your name to Purple|n
+--Lavender: |535Set your name to Lavender|n
+--Fuchsia: |503Set your name to Fuchsia|n
+Remove name color: Remove your name color, if any"""
+
+
+
[docs]classCmdNameColor(Command):
+ """
+ Set or remove a special color on your name. Just an example for the
+ easy menu selection tree contrib.
+ """
+
+ key="namecolor"
+
+
[docs]deffunc(self):
+ # This is all you have to do to initialize a menu!
+ init_tree_selection(
+ NAMECOLOR_MENU,self.caller,change_name_color,start_text="Name color options:"
+ )
+
+
+
[docs]defchange_name_color(caller,treestr,index,selection):
+ """
+ Changes a player's name color.
+
+ Args:
+ caller (obj): Character whose name to color.
+ treestr (str): String for the color change menu - unused
+ index (int): Index of menu selection - unused
+ selection (str): Selection made from the name color menu - used
+ to determine the color the player chose.
+ """
+
+ # Store the caller's uncolored name
+ ifnotcaller.db.uncolored_name:
+ caller.db.uncolored_name=caller.key
+
+ # Dictionary matching color selection names to color codes
+ colordict={
+ "Red":"|511",
+ "Pink":"|533",
+ "Maroon":"|301",
+ "Orange":"|531",
+ "Brown":"|321",
+ "Sienna":"|420",
+ "Yellow":"|551",
+ "Gold":"|540",
+ "Dandelion":"|553",
+ "Green":"|141",
+ "Lime":"|350",
+ "Forest":"|032",
+ "Blue":"|115",
+ "Cyan":"|155",
+ "Navy":"|113",
+ "Purple":"|415",
+ "Lavender":"|535",
+ "Fuchsia":"|503",
+ }
+
+ # I know this probably isn't the best way to do this. It's just an example!
+ ifselection=="Remove name color":# Player chose to remove their name color
+ caller.key=caller.db.uncolored_name
+ caller.msg("Name color removed.")
+ elifselectionincolordict:
+ newcolor=colordict[selection]# Retrieve color code based on menu selection
+ caller.key=newcolor+caller.db.uncolored_name+"|n"# Add color code to caller's name
+ caller.msg(newcolor+("Name color changed to %s!"%selection)+"|n")
Source code for evennia.contrib.turnbattle.tb_basic
+"""
+Simple turn-based combat system
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a framework for a simple turn-based combat system, similar
+to those used in D&D-style tabletop role playing games. It allows
+any character to start a fight in a room, at which point initiative
+is rolled and a turn order is established. Each participant in combat
+has a limited time to decide their action for that turn (30 seconds by
+default), and combat progresses through the turn order, looping through
+the participants until the fight ends.
+
+Only simple rolls for attacking are implemented here, but this system
+is easily extensible and can be used as the foundation for implementing
+the rules from your turn-based tabletop game of choice or making your
+own battle system.
+
+To install and test, import this module's TBBasicCharacter object into
+your game's character.py module:
+
+ from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter
+
+And change your game's character typeclass to inherit from TBBasicCharacter
+instead of the default:
+
+ class Character(TBBasicCharacter):
+
+Next, import this module into your default_cmdsets.py module:
+
+ from evennia.contrib.turnbattle import tb_basic
+
+And add the battle command set to your default command set:
+
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(tb_basic.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+fromrandomimportrandint
+fromevenniaimportDefaultCharacter,Command,default_cmds,DefaultScript
+fromevennia.commands.default.helpimportCmdHelp
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT=30# Time before turns automatically end, in seconds
+ACTIONS_PER_TURN=1# Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+
[docs]defroll_init(character):
+ """
+ Rolls a number between 1-1000 to determine initiative.
+
+ Args:
+ character (obj): The character to determine initiative for
+
+ Returns:
+ initiative (int): The character's place in initiative - higher
+ numbers go first.
+
+ Notes:
+ By default, does not reference the character and simply returns
+ a random integer from 1 to 1000.
+
+ Since the character is passed to this function, you can easily reference
+ a character's stats to determine an initiative roll - for example, if your
+ character has a 'dexterity' attribute, you can use it to give that character
+ an advantage in turn order, like so:
+
+ return (randint(1,20)) + character.db.dexterity
+
+ This way, characters with a higher dexterity will go first more often.
+ """
+ returnrandint(1,1000)
+
+
+
[docs]defget_attack(attacker,defender):
+ """
+ Returns a value for an attack roll.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ attack_value (int): Attack roll value, compared against a defense value
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, returns a random integer from 1 to 100 without using any
+ properties from either the attacker or defender.
+
+ This can easily be expanded to return a value based on characters stats,
+ equipment, and abilities. This is why the attacker and defender are passed
+ to this function, even though nothing from either one are used in this example.
+ """
+ # For this example, just return a random integer up to 100.
+ attack_value=randint(1,100)
+ returnattack_value
+
+
+
[docs]defget_defense(attacker,defender):
+ """
+ Returns a value for defense, which an attack roll must equal or exceed in order
+ for an attack to hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ defense_value (int): Defense value, compared against an attack roll
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, returns 50, not taking any properties of the defender or
+ attacker into account.
+
+ As above, this can be expanded upon based on character stats and equipment.
+ """
+ # For this example, just return 50, for about a 50/50 chance of hit.
+ defense_value=50
+ returndefense_value
+
+
+
[docs]defget_damage(attacker,defender):
+ """
+ Returns a value for damage to be deducted from the defender's HP after abilities
+ successful hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being damaged
+
+ Returns:
+ damage_value (int): Damage value, which is to be deducted from the defending
+ character's HP.
+
+ Notes:
+ By default, returns a random integer from 15 to 25 without using any
+ properties from either the attacker or defender.
+
+ Again, this can be expanded upon.
+ """
+ # For this example, just generate a number between 15 and 25.
+ damage_value=randint(15,25)
+ returndamage_value
+
+
+
[docs]defapply_damage(defender,damage):
+ """
+ Applies damage to a target, reducing their HP by the damage amount to a
+ minimum of 0.
+
+ Args:
+ defender (obj): Character taking damage
+ damage (int): Amount of damage being taken
+ """
+ defender.db.hp-=damage# Reduce defender's HP by the damage dealt.
+ # If this reduces it to 0 or less, set HP to 0.
+ ifdefender.db.hp<=0:
+ defender.db.hp=0
+
+
+
[docs]defat_defeat(defeated):
+ """
+ Announces the defeat of a fighter in combat.
+
+ Args:
+ defeated (obj): Fighter that's been defeated.
+
+ Notes:
+ All this does is announce a defeat message by default, but if you
+ want anything else to happen to defeated fighters (like putting them
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!"%defeated)
+
+
+
[docs]defresolve_attack(attacker,defender,attack_value=None,defense_value=None):
+ """
+ Resolves an attack and outputs the result.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Notes:
+ Even though the attack and defense values are calculated
+ extremely simply, they are separated out into their own functions
+ so that they are easier to expand upon.
+ """
+ # Get an attack roll from the attacker.
+ ifnotattack_value:
+ attack_value=get_attack(attacker,defender)
+ # Get a defense value from the defender.
+ ifnotdefense_value:
+ defense_value=get_defense(attacker,defender)
+ # If the attack value is lower than the defense value, miss. Otherwise, hit.
+ ifattack_value<defense_value:
+ attacker.location.msg_contents("%s's attack misses %s!"%(attacker,defender))
+ else:
+ damage_value=get_damage(attacker,defender)# Calculate damage value.
+ # Announce damage dealt and apply damage.
+ attacker.location.msg_contents(
+ "%s hits %s for %i damage!"%(attacker,defender,damage_value)
+ )
+ apply_damage(defender,damage_value)
+ # If defender HP is reduced to 0 or less, call at_defeat.
+ ifdefender.db.hp<=0:
+ at_defeat(defender)
+
+
+
[docs]defcombat_cleanup(character):
+ """
+ Cleans up all the temporary combat-related attributes on a character.
+
+ Args:
+ character (obj): Character to have their combat attributes removed
+
+ Notes:
+ Any attribute whose key begins with 'combat_' is temporary and no
+ longer needed once a fight ends.
+ """
+ forattrincharacter.attributes.all():
+ ifattr.key[:7]=="combat_":# If the attribute name starts with 'combat_'...
+ character.attributes.remove(key=attr.key)# ...then delete it!
+
+
+
[docs]defis_in_combat(character):
+ """
+ Returns true if the given character is in combat.
+
+ Args:
+ character (obj): Character to determine if is in combat or not
+
+ Returns:
+ (bool): True if in combat or False if not in combat
+ """
+ returnbool(character.db.combat_turnhandler)
+
+
+
[docs]defis_turn(character):
+ """
+ Returns true if it's currently the given character's turn in combat.
+
+ Args:
+ character (obj): Character to determine if it is their turn or not
+
+ Returns:
+ (bool): True if it is their turn or False otherwise
+ """
+ turnhandler=character.db.combat_turnhandler
+ currentchar=turnhandler.db.fighters[turnhandler.db.turn]
+ returnbool(character==currentchar)
+
+
+
[docs]defspend_action(character,actions,action_name=None):
+ """
+ Spends a character's available combat actions and checks for end of turn.
+
+ Args:
+ character (obj): Character spending the action
+ actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
+
+ Keyword Args:
+ action_name (str or None): If a string is given, sets character's last action in
+ combat to provided string
+ """
+ ifaction_name:
+ character.db.combat_lastaction=action_name
+ ifactions=="all":# If spending all actions
+ character.db.combat_actionsleft=0# Set actions to 0
+ else:
+ character.db.combat_actionsleft-=actions# Use up actions.
+ ifcharacter.db.combat_actionsleft<0:
+ character.db.combat_actionsleft=0# Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character)# Signal potential end of turn.
[docs]classTBBasicCharacter(DefaultCharacter):
+ """
+ A character able to participate in turn-based combat. Has attributes for current
+ and maximum HP, and access to combat commands.
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.max_hp=100# Set maximum HP to 100
+ self.db.hp=self.db.max_hp# Set current HP to maximum
+ """
+ Adds attributes for a character's current and maximum HP.
+ We're just going to set this value at '100' by default.
+
+ You may want to expand this to include various 'stats' that
+ can be changed at creation and factor into combat calculations.
+ """
+
+
[docs]defat_before_move(self,destination):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # Keep the character from moving if at 0 HP or in combat.
+ ifis_in_combat(self):
+ self.msg("You can't exit a room while in combat!")
+ returnFalse# Returning false keeps the character from moving.
+ ifself.db.HP<=0:
+ self.msg("You can't move, you've been defeated!")
+ returnFalse
+ returnTrue
[docs]classTBBasicTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage' command.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key="Combat Turn Handler"
+ self.interval=5# Once every 5 seconds
+ self.persistent=True
+ self.db.fighters=[]
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ forthinginself.obj.contents:
+ ifthing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ forfighterinself.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler=self
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll=sorted(self.db.fighters,key=roll_init,reverse=True)
+ self.db.fighters=ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s "%", ".join(obj.keyforobjinself.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn=0
+ self.db.timer=TURN_TIMEOUT# Set timer to turn timeout specified in options
+
+
[docs]defat_stop(self):
+ """
+ Called at script termination.
+ """
+ forfighterinself.db.fighters:
+ combat_cleanup(fighter)# Clean up the combat attributes for every fighter.
+ self.obj.db.combat_turnhandler=None# Remove reference to turn handler in location
+
+
[docs]defat_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar=self.db.fighters[
+ self.db.turn
+ ]# Note the current character in the turn order.
+ self.db.timer-=self.interval# Count down the timer.
+
+ ifself.db.timer<=0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!"%currentchar)
+ spend_action(
+ currentchar,"all",action_name="disengage"
+ )# Spend all remaining actions.
+ return
+ elifself.db.timer<=10andnotself.db.timeout_warning_given:# 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given=True
+
+
[docs]definitialize_for_combat(self,character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ combat_cleanup(character)# Clean up leftover combat attributes beforehand, just in case.
+ character.db.combat_actionsleft=(
+ 0# Actions remaining - start of turn adds to this, turn ends when it reaches 0
+ )
+ character.db.combat_turnhandler=(
+ self# Add a reference to this turn handler script to the character
+ )
+ character.db.combat_lastaction="null"# Track last action taken in combat
+
+
[docs]defstart_turn(self,character):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ Here, you only get one action per turn, but you might want to allow more than
+ one per turn, or even grant a number of actions based on a character's
+ attributes. You can even add multiple different kinds of actions, I.E. actions
+ separated for movement, by adding "character.db.combat_movesleft = 3" or
+ something similar.
+ """
+ character.db.combat_actionsleft=ACTIONS_PER_TURN# Replenish actions
+ # Prompt the character for their turn and give some information.
+ character.msg("|wIt's your turn! You have %i HP remaining.|n"%character.db.hp)
+
+
[docs]defnext_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check=True
+ forfighterinself.db.fighters:
+ if(
+ fighter.db.combat_lastaction!="disengage"
+ ):# If a character has done anything but disengage
+ disengage_check=False
+ ifdisengage_check:# All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters=0
+ forfighterinself.db.fighters:
+ iffighter.db.HP==0:
+ defeated_characters+=1# Add 1 for every fighter with 0 HP left (defeated)
+ ifdefeated_characters==(
+ len(self.db.fighters)-1
+ ):# If only one character isn't defeated
+ forfighterinself.db.fighters:
+ iffighter.db.HP!=0:
+ LastStanding=fighter# Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!"%LastStanding)
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar=self.db.fighters[self.db.turn]
+ self.db.turn+=1# Go to the next in the turn order.
+ ifself.db.turn>len(self.db.fighters)-1:
+ self.db.turn=0# Go back to the first in the turn order once you reach the end.
+ newchar=self.db.fighters[self.db.turn]# Note the new character
+ self.db.timer=TURN_TIMEOUT+self.time_until_next_repeat()# Reset the timer.
+ self.db.timeout_warning_given=False# Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!"%(currentchar,newchar))
+ self.start_turn(newchar)# Start the new character's turn.
+
+
[docs]defturn_end_check(self,character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ ifnotcharacter.db.combat_actionsleft:# Character has no actions remaining
+ self.next_turn()
+ return
+
+
[docs]defjoin_fight(self,character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn,character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn+=1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
[docs]classCmdFight(Command):
+ """
+ Starts a fight with everyone in the same room as you.
+
+ Usage:
+ fight
+
+ When you start a fight, everyone in the room who is able to
+ fight is added to combat, and a turn order is randomly rolled.
+ When it's your turn, you can attack other characters.
+ """
+
+ key="fight"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ here=self.caller.location
+ fighters=[]
+
+ ifnotself.caller.db.hp:# If you don't have any hp
+ self.caller.msg("You can't start a fight if you've been defeated!")
+ return
+ ifis_in_combat(self.caller):# Already in a fight
+ self.caller.msg("You're already in a fight!")
+ return
+ forthinginhere.contents:# Test everything in the room to add it to the fight.
+ ifthing.db.HP:# If the object has HP...
+ fighters.append(thing)# ...then add it to the fight.
+ iflen(fighters)<=1:# If you're the only able fighter in the room
+ self.caller.msg("There's nobody here to fight!")
+ return
+ ifhere.db.combat_turnhandler:# If there's already a fight going on...
+ here.msg_contents("%s joins the fight!"%self.caller)
+ here.db.combat_turnhandler.join_fight(self.caller)# Join the fight!
+ return
+ here.msg_contents("%s starts a fight!"%self.caller)
+ # Add a turn handler script to the room, which starts combat.
+ here.scripts.add("contrib.turnbattle.tb_basic.TBBasicTurnHandler")
+ # Remember you'll have to change the path to the script if you copy this code to your own modules!
+
+
+
[docs]classCmdAttack(Command):
+ """
+ Attacks another character.
+
+ Usage:
+ attack <target>
+
+ When in a fight, you may attack another character. The attack has
+ a chance to hit, and if successful, will deal damage.
+ """
+
+ key="attack"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ ifnotis_in_combat(self.caller):# If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ ifnotself.caller.db.hp:# Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ attacker=self.caller
+ defender=self.caller.search(self.args)
+
+ ifnotdefender:# No valid target given.
+ return
+
+ ifnotdefender.db.hp:# Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ ifattacker==defender:# Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker,defender)
+ spend_action(self.caller,1,action_name="attack")# Use up one action.
+
+
+
[docs]classCmdPass(Command):
+ """
+ Passes on your turn.
+
+ Usage:
+ pass
+
+ When in a fight, you can use this command to end your turn early, even
+ if there are still any actions you can take.
+ """
+
+ key="pass"
+ aliases=["wait","hold"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# Can only pass a turn in combat.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# Can only pass if it's your turn.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents(
+ "%s takes no further action, passing the turn."%self.caller
+ )
+ spend_action(self.caller,"all",action_name="pass")# Spend all remaining actions.
+
+
+
[docs]classCmdDisengage(Command):
+ """
+ Passes your turn and attempts to end combat.
+
+ Usage:
+ disengage
+
+ Ends your turn early and signals that you're trying to end
+ the fight. If all participants in a fight disengage, the
+ fight ends.
+ """
+
+ key="disengage"
+ aliases=["spare"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# If you're not in combat
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s disengages, ready to stop fighting."%self.caller)
+ spend_action(self.caller,"all",action_name="disengage")# Spend all remaining actions.
+ """
+ The action_name kwarg sets the character's last action to "disengage", which is checked by
+ the turn handler script to see if all fighters have disengaged.
+ """
+
+
+
[docs]classCmdRest(Command):
+ """
+ Recovers damage.
+
+ Usage:
+ rest
+
+ Resting recovers your HP to its maximum, but you can only
+ rest if you're not in a fight.
+ """
+
+ key="rest"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+
+ ifis_in_combat(self.caller):# If you're in combat
+ self.caller.msg("You can't rest while you're in combat.")
+ return
+
+ self.caller.db.hp=self.caller.db.max_hp# Set current HP to maximum
+ self.caller.location.msg_contents("%s rests to recover HP."%self.caller)
+ """
+ You'll probably want to replace this with your own system for recovering HP.
+ """
+
+
+
[docs]classCmdCombatHelp(CmdHelp):
+ """
+ View help or a list of topics
+
+ Usage:
+ help <topic or command>
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+
+ # Just like the default help command, but will give quick
+ # tips on combat when used in a fight with no arguments.
+
+
[docs]deffunc(self):
+ ifis_in_combat(self.caller)andnotself.args:# In combat and entered 'help' alone
+ self.caller.msg(
+ "Available combat commands:|/"
+ +"|wAttack:|n Attack a target, attempting to deal damage.|/"
+ +"|wPass:|n Pass your turn without further action.|/"
+ +"|wDisengage:|n End your turn and attempt to end combat.|/"
+ )
+ else:
+ super().func()# Call the default help command
+
+
+
[docs]classBattleCmdSet(default_cmds.CharacterCmdSet):
+ """
+ This command set includes all the commmands used in the battle system.
+ """
+
+ key="DefaultCharacter"
+
+
Source code for evennia.contrib.turnbattle.tb_equip
+"""
+Simple turn-based combat system with equipment
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' contrib with a basic system for
+weapons and armor implemented. Weapons can have unique damage ranges
+and accuracy modifiers, while armor can reduce incoming damage and
+change one's chance of getting hit. The 'wield' command is used to
+equip weapons and the 'don' command is used to equip armor.
+
+Some prototypes are included at the end of this module - feel free to
+copy them into your game's prototypes.py module in your 'world' folder
+and create them with the @spawn command. (See the tutorial for using
+the @spawn command for details.)
+
+For the example equipment given, heavier weapons deal more damage
+but are less accurate, while light weapons are more accurate but
+deal less damage. Similarly, heavy armor reduces incoming damage by
+a lot but increases your chance of getting hit, while light armor is
+easier to dodge in but reduces incoming damage less. Light weapons are
+more effective against lightly armored opponents and heavy weapons are
+more damaging against heavily armored foes, but heavy weapons and armor
+are slightly better than light weapons and armor overall.
+
+This is a fairly bare implementation of equipment that is meant to be
+expanded to fit your game - weapon and armor slots, damage types and
+damage bonuses, etc. should be fairly simple to implement according to
+the rules of your preferred system or the needs of your own game.
+
+To install and test, import this module's TBEquipCharacter object into
+your game's character.py module:
+
+ from evennia.contrib.turnbattle.tb_equip import TBEquipCharacter
+
+And change your game's character typeclass to inherit from TBEquipCharacter
+instead of the default:
+
+ class Character(TBEquipCharacter):
+
+Next, import this module into your default_cmdsets.py module:
+
+ from evennia.contrib.turnbattle import tb_equip
+
+And add the battle command set to your default command set:
+
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(tb_equip.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+fromrandomimportrandint
+fromevenniaimportDefaultCharacter,Command,default_cmds,DefaultScript,DefaultObject
+fromevennia.commands.default.helpimportCmdHelp
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT=30# Time before turns automatically end, in seconds
+ACTIONS_PER_TURN=1# Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+
[docs]defroll_init(character):
+ """
+ Rolls a number between 1-1000 to determine initiative.
+
+ Args:
+ character (obj): The character to determine initiative for
+
+ Returns:
+ initiative (int): The character's place in initiative - higher
+ numbers go first.
+
+ Notes:
+ By default, does not reference the character and simply returns
+ a random integer from 1 to 1000.
+
+ Since the character is passed to this function, you can easily reference
+ a character's stats to determine an initiative roll - for example, if your
+ character has a 'dexterity' attribute, you can use it to give that character
+ an advantage in turn order, like so:
+
+ return (randint(1,20)) + character.db.dexterity
+
+ This way, characters with a higher dexterity will go first more often.
+ """
+ returnrandint(1,1000)
+
+
+
[docs]defget_attack(attacker,defender):
+ """
+ Returns a value for an attack roll.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ attack_value (int): Attack roll value, compared against a defense value
+ to determine whether an attack hits or misses.
+
+ Notes:
+ In this example, a weapon's accuracy bonus is factored into the attack
+ roll. Lighter weapons are more accurate but less damaging, and heavier
+ weapons are less accurate but deal more damage. Of course, you can
+ change this paradigm completely in your own game.
+ """
+ # Start with a roll from 1 to 100.
+ attack_value=randint(1,100)
+ accuracy_bonus=0
+ # If armed, add weapon's accuracy bonus.
+ ifattacker.db.wielded_weapon:
+ weapon=attacker.db.wielded_weapon
+ accuracy_bonus+=weapon.db.accuracy_bonus
+ # If unarmed, use character's unarmed accuracy bonus.
+ else:
+ accuracy_bonus+=attacker.db.unarmed_accuracy
+ # Add the accuracy bonus to the attack roll.
+ attack_value+=accuracy_bonus
+ returnattack_value
+
+
+
[docs]defget_defense(attacker,defender):
+ """
+ Returns a value for defense, which an attack roll must equal or exceed in order
+ for an attack to hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ defense_value (int): Defense value, compared against an attack roll
+ to determine whether an attack hits or misses.
+
+ Notes:
+ Characters are given a default defense value of 50 which can be
+ modified up or down by armor. In this example, wearing armor actually
+ makes you a little easier to hit, but reduces incoming damage.
+ """
+ # Start with a defense value of 50 for a 50/50 chance to hit.
+ defense_value=50
+ # Modify this value based on defender's armor.
+ ifdefender.db.worn_armor:
+ armor=defender.db.worn_armor
+ defense_value+=armor.db.defense_modifier
+ returndefense_value
+
+
+
[docs]defget_damage(attacker,defender):
+ """
+ Returns a value for damage to be deducted from the defender's HP after abilities
+ successful hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being damaged
+
+ Returns:
+ damage_value (int): Damage value, which is to be deducted from the defending
+ character's HP.
+
+ Notes:
+ Damage is determined by the attacker's wielded weapon, or the attacker's
+ unarmed damage range if no weapon is wielded. Incoming damage is reduced
+ by the defender's armor.
+ """
+ damage_value=0
+ # Generate a damage value from wielded weapon if armed
+ ifattacker.db.wielded_weapon:
+ weapon=attacker.db.wielded_weapon
+ # Roll between minimum and maximum damage
+ damage_value=randint(weapon.db.damage_range[0],weapon.db.damage_range[1])
+ # Use attacker's unarmed damage otherwise
+ else:
+ damage_value=randint(
+ attacker.db.unarmed_damage_range[0],attacker.db.unarmed_damage_range[1]
+ )
+ # If defender is armored, reduce incoming damage
+ ifdefender.db.worn_armor:
+ armor=defender.db.worn_armor
+ damage_value-=armor.db.damage_reduction
+ # Make sure minimum damage is 0
+ ifdamage_value<0:
+ damage_value=0
+ returndamage_value
+
+
+
[docs]defapply_damage(defender,damage):
+ """
+ Applies damage to a target, reducing their HP by the damage amount to a
+ minimum of 0.
+
+ Args:
+ defender (obj): Character taking damage
+ damage (int): Amount of damage being taken
+ """
+ defender.db.hp-=damage# Reduce defender's HP by the damage dealt.
+ # If this reduces it to 0 or less, set HP to 0.
+ ifdefender.db.hp<=0:
+ defender.db.hp=0
+
+
+
[docs]defat_defeat(defeated):
+ """
+ Announces the defeat of a fighter in combat.
+
+ Args:
+ defeated (obj): Fighter that's been defeated.
+
+ Notes:
+ All this does is announce a defeat message by default, but if you
+ want anything else to happen to defeated fighters (like putting them
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!"%defeated)
+
+
+
[docs]defresolve_attack(attacker,defender,attack_value=None,defense_value=None):
+ """
+ Resolves an attack and outputs the result.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Notes:
+ Even though the attack and defense values are calculated
+ extremely simply, they are separated out into their own functions
+ so that they are easier to expand upon.
+ """
+ # Get the attacker's weapon type to reference in combat messages.
+ attackers_weapon="attack"
+ ifattacker.db.wielded_weapon:
+ weapon=attacker.db.wielded_weapon
+ attackers_weapon=weapon.db.weapon_type_name
+ # Get an attack roll from the attacker.
+ ifnotattack_value:
+ attack_value=get_attack(attacker,defender)
+ # Get a defense value from the defender.
+ ifnotdefense_value:
+ defense_value=get_defense(attacker,defender)
+ # If the attack value is lower than the defense value, miss. Otherwise, hit.
+ ifattack_value<defense_value:
+ attacker.location.msg_contents(
+ "%s's %s misses %s!"%(attacker,attackers_weapon,defender)
+ )
+ else:
+ damage_value=get_damage(attacker,defender)# Calculate damage value.
+ # Announce damage dealt and apply damage.
+ ifdamage_value>0:
+ attacker.location.msg_contents(
+ "%s's %s strikes %s for %i damage!"
+ %(attacker,attackers_weapon,defender,damage_value)
+ )
+ else:
+ attacker.location.msg_contents(
+ "%s's %s bounces harmlessly off %s!"%(attacker,attackers_weapon,defender)
+ )
+ apply_damage(defender,damage_value)
+ # If defender HP is reduced to 0 or less, call at_defeat.
+ ifdefender.db.hp<=0:
+ at_defeat(defender)
+
+
+
[docs]defcombat_cleanup(character):
+ """
+ Cleans up all the temporary combat-related attributes on a character.
+
+ Args:
+ character (obj): Character to have their combat attributes removed
+
+ Notes:
+ Any attribute whose key begins with 'combat_' is temporary and no
+ longer needed once a fight ends.
+ """
+ forattrincharacter.attributes.all():
+ ifattr.key[:7]=="combat_":# If the attribute name starts with 'combat_'...
+ character.attributes.remove(key=attr.key)# ...then delete it!
+
+
+
[docs]defis_in_combat(character):
+ """
+ Returns true if the given character is in combat.
+
+ Args:
+ character (obj): Character to determine if is in combat or not
+
+ Returns:
+ (bool): True if in combat or False if not in combat
+ """
+ returnbool(character.db.combat_turnhandler)
+
+
+
[docs]defis_turn(character):
+ """
+ Returns true if it's currently the given character's turn in combat.
+
+ Args:
+ character (obj): Character to determine if it is their turn or not
+
+ Returns:
+ (bool): True if it is their turn or False otherwise
+ """
+ turnhandler=character.db.combat_turnhandler
+ currentchar=turnhandler.db.fighters[turnhandler.db.turn]
+ returnbool(character==currentchar)
+
+
+
[docs]defspend_action(character,actions,action_name=None):
+ """
+ Spends a character's available combat actions and checks for end of turn.
+
+ Args:
+ character (obj): Character spending the action
+ actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
+
+ Keyword Args:
+ action_name (str or None): If a string is given, sets character's last action in
+ combat to provided string
+ """
+ ifaction_name:
+ character.db.combat_lastaction=action_name
+ ifactions=="all":# If spending all actions
+ character.db.combat_actionsleft=0# Set actions to 0
+ else:
+ character.db.combat_actionsleft-=actions# Use up actions.
+ ifcharacter.db.combat_actionsleft<0:
+ character.db.combat_actionsleft=0# Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character)# Signal potential end of turn.
[docs]classTBEquipTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage' command.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key="Combat Turn Handler"
+ self.interval=5# Once every 5 seconds
+ self.persistent=True
+ self.db.fighters=[]
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ forthinginself.obj.contents:
+ ifthing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ forfighterinself.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler=self
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll=sorted(self.db.fighters,key=roll_init,reverse=True)
+ self.db.fighters=ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s "%", ".join(obj.keyforobjinself.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn=0
+ self.db.timer=TURN_TIMEOUT# Set timer to turn timeout specified in options
+
+
[docs]defat_stop(self):
+ """
+ Called at script termination.
+ """
+ forfighterinself.db.fighters:
+ combat_cleanup(fighter)# Clean up the combat attributes for every fighter.
+ self.obj.db.combat_turnhandler=None# Remove reference to turn handler in location
+
+
[docs]defat_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar=self.db.fighters[
+ self.db.turn
+ ]# Note the current character in the turn order.
+ self.db.timer-=self.interval# Count down the timer.
+
+ ifself.db.timer<=0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!"%currentchar)
+ spend_action(
+ currentchar,"all",action_name="disengage"
+ )# Spend all remaining actions.
+ return
+ elifself.db.timer<=10andnotself.db.timeout_warning_given:# 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given=True
+
+
[docs]definitialize_for_combat(self,character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ combat_cleanup(character)# Clean up leftover combat attributes beforehand, just in case.
+ character.db.combat_actionsleft=(
+ 0# Actions remaining - start of turn adds to this, turn ends when it reaches 0
+ )
+ character.db.combat_turnhandler=(
+ self# Add a reference to this turn handler script to the character
+ )
+ character.db.combat_lastaction="null"# Track last action taken in combat
+
+
[docs]defstart_turn(self,character):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ Here, you only get one action per turn, but you might want to allow more than
+ one per turn, or even grant a number of actions based on a character's
+ attributes. You can even add multiple different kinds of actions, I.E. actions
+ separated for movement, by adding "character.db.combat_movesleft = 3" or
+ something similar.
+ """
+ character.db.combat_actionsleft=ACTIONS_PER_TURN# Replenish actions
+ # Prompt the character for their turn and give some information.
+ character.msg("|wIt's your turn! You have %i HP remaining.|n"%character.db.hp)
+
+
[docs]defnext_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check=True
+ forfighterinself.db.fighters:
+ if(
+ fighter.db.combat_lastaction!="disengage"
+ ):# If a character has done anything but disengage
+ disengage_check=False
+ ifdisengage_check:# All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters=0
+ forfighterinself.db.fighters:
+ iffighter.db.HP==0:
+ defeated_characters+=1# Add 1 for every fighter with 0 HP left (defeated)
+ ifdefeated_characters==(
+ len(self.db.fighters)-1
+ ):# If only one character isn't defeated
+ forfighterinself.db.fighters:
+ iffighter.db.HP!=0:
+ LastStanding=fighter# Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!"%LastStanding)
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar=self.db.fighters[self.db.turn]
+ self.db.turn+=1# Go to the next in the turn order.
+ ifself.db.turn>len(self.db.fighters)-1:
+ self.db.turn=0# Go back to the first in the turn order once you reach the end.
+ newchar=self.db.fighters[self.db.turn]# Note the new character
+ self.db.timer=TURN_TIMEOUT+self.time_until_next_repeat()# Reset the timer.
+ self.db.timeout_warning_given=False# Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!"%(currentchar,newchar))
+ self.start_turn(newchar)# Start the new character's turn.
+
+
[docs]defturn_end_check(self,character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ ifnotcharacter.db.combat_actionsleft:# Character has no actions remaining
+ self.next_turn()
+ return
+
+
[docs]defjoin_fight(self,character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn,character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn+=1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
[docs]classTBEWeapon(DefaultObject):
+ """
+ A weapon which can be wielded in combat with the 'wield' command.
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.damage_range=(15,25)# Minimum and maximum damage on hit
+ self.db.accuracy_bonus=0# Bonus to attack rolls (or penalty if negative)
+ self.db.weapon_type_name=(
+ "weapon"# Single word for weapon - I.E. "dagger", "staff", "scimitar"
+ )
+
+
[docs]defat_drop(self,dropper):
+ """
+ Stop being wielded if dropped.
+ """
+ ifdropper.db.wielded_weapon==self:
+ dropper.db.wielded_weapon=None
+ dropper.location.msg_contents("%s stops wielding %s."%(dropper,self))
+
+
[docs]defat_give(self,giver,getter):
+ """
+ Stop being wielded if given.
+ """
+ ifgiver.db.wielded_weapon==self:
+ giver.db.wielded_weapon=None
+ giver.location.msg_contents("%s stops wielding %s."%(giver,self))
+
+
+
[docs]classTBEArmor(DefaultObject):
+ """
+ A set of armor which can be worn with the 'don' command.
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.damage_reduction=4# Amount of incoming damage reduced by armor
+ self.db.defense_modifier=(
+ -4
+ )# Amount to modify defense value (pos = harder to hit, neg = easier)
+
+
[docs]defat_before_drop(self,dropper):
+ """
+ Can't drop in combat.
+ """
+ ifis_in_combat(dropper):
+ dropper.msg("You can't doff armor in a fight!")
+ returnFalse
+ returnTrue
+
+
[docs]defat_drop(self,dropper):
+ """
+ Stop being wielded if dropped.
+ """
+ ifdropper.db.worn_armor==self:
+ dropper.db.worn_armor=None
+ dropper.location.msg_contents("%s removes %s."%(dropper,self))
+
+
[docs]defat_before_give(self,giver,getter):
+ """
+ Can't give away in combat.
+ """
+ ifis_in_combat(giver):
+ dropper.msg("You can't doff armor in a fight!")
+ returnFalse
+ returnTrue
+
+
[docs]defat_give(self,giver,getter):
+ """
+ Stop being wielded if given.
+ """
+ ifgiver.db.worn_armor==self:
+ giver.db.worn_armor=None
+ giver.location.msg_contents("%s removes %s."%(giver,self))
+
+
+
[docs]classTBEquipCharacter(DefaultCharacter):
+ """
+ A character able to participate in turn-based combat. Has attributes for current
+ and maximum HP, and access to combat commands.
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.max_hp=100# Set maximum HP to 100
+ self.db.hp=self.db.max_hp# Set current HP to maximum
+ self.db.wielded_weapon=None# Currently used weapon
+ self.db.worn_armor=None# Currently worn armor
+ self.db.unarmed_damage_range=(5,15)# Minimum and maximum unarmed damage
+ self.db.unarmed_accuracy=30# Accuracy bonus for unarmed attacks
+
+ """
+ Adds attributes for a character's current and maximum HP.
+ We're just going to set this value at '100' by default.
+
+ You may want to expand this to include various 'stats' that
+ can be changed at creation and factor into combat calculations.
+ """
+
+
[docs]defat_before_move(self,destination):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # Keep the character from moving if at 0 HP or in combat.
+ ifis_in_combat(self):
+ self.msg("You can't exit a room while in combat!")
+ returnFalse# Returning false keeps the character from moving.
+ ifself.db.HP<=0:
+ self.msg("You can't move, you've been defeated!")
+ returnFalse
+ returnTrue
[docs]classCmdFight(Command):
+ """
+ Starts a fight with everyone in the same room as you.
+
+ Usage:
+ fight
+
+ When you start a fight, everyone in the room who is able to
+ fight is added to combat, and a turn order is randomly rolled.
+ When it's your turn, you can attack other characters.
+ """
+
+ key="fight"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ here=self.caller.location
+ fighters=[]
+
+ ifnotself.caller.db.hp:# If you don't have any hp
+ self.caller.msg("You can't start a fight if you've been defeated!")
+ return
+ ifis_in_combat(self.caller):# Already in a fight
+ self.caller.msg("You're already in a fight!")
+ return
+ forthinginhere.contents:# Test everything in the room to add it to the fight.
+ ifthing.db.HP:# If the object has HP...
+ fighters.append(thing)# ...then add it to the fight.
+ iflen(fighters)<=1:# If you're the only able fighter in the room
+ self.caller.msg("There's nobody here to fight!")
+ return
+ ifhere.db.combat_turnhandler:# If there's already a fight going on...
+ here.msg_contents("%s joins the fight!"%self.caller)
+ here.db.combat_turnhandler.join_fight(self.caller)# Join the fight!
+ return
+ here.msg_contents("%s starts a fight!"%self.caller)
+ # Add a turn handler script to the room, which starts combat.
+ here.scripts.add("contrib.turnbattle.tb_equip.TBEquipTurnHandler")
+ # Remember you'll have to change the path to the script if you copy this code to your own modules!
+
+
+
[docs]classCmdAttack(Command):
+ """
+ Attacks another character.
+
+ Usage:
+ attack <target>
+
+ When in a fight, you may attack another character. The attack has
+ a chance to hit, and if successful, will deal damage.
+ """
+
+ key="attack"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ ifnotis_in_combat(self.caller):# If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ ifnotself.caller.db.hp:# Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ attacker=self.caller
+ defender=self.caller.search(self.args)
+
+ ifnotdefender:# No valid target given.
+ return
+
+ ifnotdefender.db.hp:# Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ ifattacker==defender:# Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker,defender)
+ spend_action(self.caller,1,action_name="attack")# Use up one action.
+
+
+
[docs]classCmdPass(Command):
+ """
+ Passes on your turn.
+
+ Usage:
+ pass
+
+ When in a fight, you can use this command to end your turn early, even
+ if there are still any actions you can take.
+ """
+
+ key="pass"
+ aliases=["wait","hold"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# Can only pass a turn in combat.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# Can only pass if it's your turn.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents(
+ "%s takes no further action, passing the turn."%self.caller
+ )
+ spend_action(self.caller,"all",action_name="pass")# Spend all remaining actions.
+
+
+
[docs]classCmdDisengage(Command):
+ """
+ Passes your turn and attempts to end combat.
+
+ Usage:
+ disengage
+
+ Ends your turn early and signals that you're trying to end
+ the fight. If all participants in a fight disengage, the
+ fight ends.
+ """
+
+ key="disengage"
+ aliases=["spare"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# If you're not in combat
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s disengages, ready to stop fighting."%self.caller)
+ spend_action(self.caller,"all",action_name="disengage")# Spend all remaining actions.
+ """
+ The action_name kwarg sets the character's last action to "disengage", which is checked by
+ the turn handler script to see if all fighters have disengaged.
+ """
+
+
+
[docs]classCmdRest(Command):
+ """
+ Recovers damage.
+
+ Usage:
+ rest
+
+ Resting recovers your HP to its maximum, but you can only
+ rest if you're not in a fight.
+ """
+
+ key="rest"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+
+ ifis_in_combat(self.caller):# If you're in combat
+ self.caller.msg("You can't rest while you're in combat.")
+ return
+
+ self.caller.db.hp=self.caller.db.max_hp# Set current HP to maximum
+ self.caller.location.msg_contents("%s rests to recover HP."%self.caller)
+ """
+ You'll probably want to replace this with your own system for recovering HP.
+ """
+
+
+
[docs]classCmdCombatHelp(CmdHelp):
+ """
+ View help or a list of topics
+
+ Usage:
+ help <topic or command>
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+
+ # Just like the default help command, but will give quick
+ # tips on combat when used in a fight with no arguments.
+
+
[docs]deffunc(self):
+ ifis_in_combat(self.caller)andnotself.args:# In combat and entered 'help' alone
+ self.caller.msg(
+ "Available combat commands:|/"
+ +"|wAttack:|n Attack a target, attempting to deal damage.|/"
+ +"|wPass:|n Pass your turn without further action.|/"
+ +"|wDisengage:|n End your turn and attempt to end combat.|/"
+ )
+ else:
+ super().func()# Call the default help command
+
+
+
[docs]classCmdWield(Command):
+ """
+ Wield a weapon you are carrying
+
+ Usage:
+ wield <weapon>
+
+ Select a weapon you are carrying to wield in combat. If
+ you are already wielding another weapon, you will switch
+ to the weapon you specify instead. Using this command in
+ combat will spend your action for your turn. Use the
+ "unwield" command to stop wielding any weapon you are
+ currently wielding.
+ """
+
+ key="wield"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ # If in combat, check to see if it's your turn.
+ ifis_in_combat(self.caller):
+ ifnotis_turn(self.caller):
+ self.caller.msg("You can only do that on your turn.")
+ return
+ ifnotself.args:
+ self.caller.msg("Usage: wield <obj>")
+ return
+ weapon=self.caller.search(self.args,candidates=self.caller.contents)
+ ifnotweapon:
+ return
+ ifnotweapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon",exact=True):
+ self.caller.msg("That's not a weapon!")
+ # Remember to update the path to the weapon typeclass if you move this module!
+ return
+
+ ifnotself.caller.db.wielded_weapon:
+ self.caller.db.wielded_weapon=weapon
+ self.caller.location.msg_contents("%s wields %s."%(self.caller,weapon))
+ else:
+ old_weapon=self.caller.db.wielded_weapon
+ self.caller.db.wielded_weapon=weapon
+ self.caller.location.msg_contents(
+ "%s lowers %s and wields %s."%(self.caller,old_weapon,weapon)
+ )
+ # Spend an action if in combat.
+ ifis_in_combat(self.caller):
+ spend_action(self.caller,1,action_name="wield")# Use up one action.
+
+
+
[docs]classCmdUnwield(Command):
+ """
+ Stop wielding a weapon.
+
+ Usage:
+ unwield
+
+ After using this command, you will stop wielding any
+ weapon you are currently wielding and become unarmed.
+ """
+
+ key="unwield"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ # If in combat, check to see if it's your turn.
+ ifis_in_combat(self.caller):
+ ifnotis_turn(self.caller):
+ self.caller.msg("You can only do that on your turn.")
+ return
+ ifnotself.caller.db.wielded_weapon:
+ self.caller.msg("You aren't wielding a weapon!")
+ else:
+ old_weapon=self.caller.db.wielded_weapon
+ self.caller.db.wielded_weapon=None
+ self.caller.location.msg_contents("%s lowers %s."%(self.caller,old_weapon))
+
+
+
[docs]classCmdDon(Command):
+ """
+ Don armor that you are carrying
+
+ Usage:
+ don <armor>
+
+ Select armor to wear in combat. You can't use this
+ command in the middle of a fight. Use the "doff"
+ command to remove any armor you are wearing.
+ """
+
+ key="don"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ # Can't do this in combat
+ ifis_in_combat(self.caller):
+ self.caller.msg("You can't don armor in a fight!")
+ return
+ ifnotself.args:
+ self.caller.msg("Usage: don <obj>")
+ return
+ armor=self.caller.search(self.args,candidates=self.caller.contents)
+ ifnotarmor:
+ return
+ ifnotarmor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor",exact=True):
+ self.caller.msg("That's not armor!")
+ # Remember to update the path to the armor typeclass if you move this module!
+ return
+
+ ifnotself.caller.db.worn_armor:
+ self.caller.db.worn_armor=armor
+ self.caller.location.msg_contents("%s dons %s."%(self.caller,armor))
+ else:
+ old_armor=self.caller.db.worn_armor
+ self.caller.db.worn_armor=armor
+ self.caller.location.msg_contents(
+ "%s removes %s and dons %s."%(self.caller,old_armor,armor)
+ )
+
+
+
[docs]classCmdDoff(Command):
+ """
+ Stop wearing armor.
+
+ Usage:
+ doff
+
+ After using this command, you will stop wearing any
+ armor you are currently using and become unarmored.
+ You can't use this command in combat.
+ """
+
+ key="doff"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ # Can't do this in combat
+ ifis_in_combat(self.caller):
+ self.caller.msg("You can't doff armor in a fight!")
+ return
+ ifnotself.caller.db.worn_armor:
+ self.caller.msg("You aren't wearing any armor!")
+ else:
+ old_armor=self.caller.db.worn_armor
+ self.caller.db.worn_armor=None
+ self.caller.location.msg_contents("%s removes %s."%(self.caller,old_armor))
+
+
+
[docs]classBattleCmdSet(default_cmds.CharacterCmdSet):
+ """
+ This command set includes all the commmands used in the battle system.
+ """
+
+ key="DefaultCharacter"
+
+
Source code for evennia.contrib.turnbattle.tb_items
+"""
+Simple turn-based combat system with items and status effects
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' combat system that includes
+conditions and usable items, which can instill these conditions, cure
+them, or do just about anything else.
+
+Conditions are stored on characters as a dictionary, where the key
+is the name of the condition and the value is a list of two items:
+an integer representing the number of turns left until the condition
+runs out, and the character upon whose turn the condition timer is
+ticked down. Unlike most combat-related attributes, conditions aren't
+wiped once combat ends - if out of combat, they tick down in real time
+instead.
+
+This module includes a number of example conditions:
+
+ Regeneration: Character recovers HP every turn
+ Poisoned: Character loses HP every turn
+ Accuracy Up: +25 to character's attack rolls
+ Accuracy Down: -25 to character's attack rolls
+ Damage Up: +5 to character's damage
+ Damage Down: -5 to character's damage
+ Defense Up: +15 to character's defense
+ Defense Down: -15 to character's defense
+ Haste: +1 action per turn
+ Paralyzed: No actions per turn
+ Frightened: Character can't use the 'attack' command
+
+Since conditions can have a wide variety of effects, their code is
+scattered throughout the other functions wherever they may apply.
+
+Items aren't given any sort of special typeclass - instead, whether or
+not an object counts as an item is determined by its attributes. To make
+an object into an item, it must have the attribute 'item_func', with
+the value given as a callable - this is the function that will be called
+when an item is used. Other properties of the item, such as how many
+uses it has, whether it's destroyed when its uses are depleted, and such
+can be specified on the item as well, but they are optional.
+
+To install and test, import this module's TBItemsCharacter object into
+your game's character.py module:
+
+ from evennia.contrib.turnbattle.tb_items import TBItemsCharacter
+
+And change your game's character typeclass to inherit from TBItemsCharacter
+instead of the default:
+
+ class Character(TBItemsCharacter):
+
+Next, import this module into your default_cmdsets.py module:
+
+ from evennia.contrib.turnbattle import tb_items
+
+And add the battle command set to your default command set:
+
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(tb_items.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+fromrandomimportrandint
+fromevenniaimportDefaultCharacter,Command,default_cmds,DefaultScript
+fromevennia.commands.default.muxcommandimportMuxCommand
+fromevennia.commands.default.helpimportCmdHelp
+fromevennia.prototypes.spawnerimportspawn
+fromevenniaimportTICKER_HANDLERastickerhandler
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT=30# Time before turns automatically end, in seconds
+ACTIONS_PER_TURN=1# Number of actions allowed per turn
+NONCOMBAT_TURN_TIME=30# Time per turn count out of combat
+
+# Condition options start here.
+# If you need to make changes to how your conditions work later,
+# it's best to put the easily tweakable values all in one place!
+
+REGEN_RATE=(4,8)# Min and max HP regen for Regeneration
+POISON_RATE=(4,8)# Min and max damage for Poisoned
+ACC_UP_MOD=25# Accuracy Up attack roll bonus
+ACC_DOWN_MOD=-25# Accuracy Down attack roll penalty
+DMG_UP_MOD=5# Damage Up damage roll bonus
+DMG_DOWN_MOD=-5# Damage Down damage roll penalty
+DEF_UP_MOD=15# Defense Up defense bonus
+DEF_DOWN_MOD=-15# Defense Down defense penalty
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+
[docs]defroll_init(character):
+ """
+ Rolls a number between 1-1000 to determine initiative.
+
+ Args:
+ character (obj): The character to determine initiative for
+
+ Returns:
+ initiative (int): The character's place in initiative - higher
+ numbers go first.
+
+ Notes:
+ By default, does not reference the character and simply returns
+ a random integer from 1 to 1000.
+
+ Since the character is passed to this function, you can easily reference
+ a character's stats to determine an initiative roll - for example, if your
+ character has a 'dexterity' attribute, you can use it to give that character
+ an advantage in turn order, like so:
+
+ return (randint(1,20)) + character.db.dexterity
+
+ This way, characters with a higher dexterity will go first more often.
+ """
+ returnrandint(1,1000)
+
+
+
[docs]defget_attack(attacker,defender):
+ """
+ Returns a value for an attack roll.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ attack_value (int): Attack roll value, compared against a defense value
+ to determine whether an attack hits or misses.
+
+ Notes:
+ This is where conditions affecting attack rolls are applied, as well.
+ Accuracy Up and Accuracy Down are also accounted for in itemfunc_attack(),
+ so that attack items' accuracy is affected as well.
+ """
+ # For this example, just return a random integer up to 100.
+ attack_value=randint(1,100)
+ # Add to the roll if the attacker has the "Accuracy Up" condition.
+ if"Accuracy Up"inattacker.db.conditions:
+ attack_value+=ACC_UP_MOD
+ # Subtract from the roll if the attack has the "Accuracy Down" condition.
+ if"Accuracy Down"inattacker.db.conditions:
+ attack_value+=ACC_DOWN_MOD
+ returnattack_value
+
+
+
[docs]defget_defense(attacker,defender):
+ """
+ Returns a value for defense, which an attack roll must equal or exceed in order
+ for an attack to hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ defense_value (int): Defense value, compared against an attack roll
+ to determine whether an attack hits or misses.
+
+ Notes:
+ This is where conditions affecting defense are accounted for.
+ """
+ # For this example, just return 50, for about a 50/50 chance of hit.
+ defense_value=50
+ # Add to defense if the defender has the "Defense Up" condition.
+ if"Defense Up"indefender.db.conditions:
+ defense_value+=DEF_UP_MOD
+ # Subtract from defense if the defender has the "Defense Down" condition.
+ if"Defense Down"indefender.db.conditions:
+ defense_value+=DEF_DOWN_MOD
+ returndefense_value
+
+
+
[docs]defget_damage(attacker,defender):
+ """
+ Returns a value for damage to be deducted from the defender's HP after abilities
+ successful hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being damaged
+
+ Returns:
+ damage_value (int): Damage value, which is to be deducted from the defending
+ character's HP.
+
+ Notes:
+ This is where conditions affecting damage are accounted for. Since attack items
+ roll their own damage in itemfunc_attack(), their damage is unaffected by any
+ conditions.
+ """
+ # For this example, just generate a number between 15 and 25.
+ damage_value=randint(15,25)
+ # Add to damage roll if attacker has the "Damage Up" condition.
+ if"Damage Up"inattacker.db.conditions:
+ damage_value+=DMG_UP_MOD
+ # Subtract from the roll if the attacker has the "Damage Down" condition.
+ if"Damage Down"inattacker.db.conditions:
+ damage_value+=DMG_DOWN_MOD
+ returndamage_value
+
+
+
[docs]defapply_damage(defender,damage):
+ """
+ Applies damage to a target, reducing their HP by the damage amount to a
+ minimum of 0.
+
+ Args:
+ defender (obj): Character taking damage
+ damage (int): Amount of damage being taken
+ """
+ defender.db.hp-=damage# Reduce defender's HP by the damage dealt.
+ # If this reduces it to 0 or less, set HP to 0.
+ ifdefender.db.hp<=0:
+ defender.db.hp=0
+
+
+
[docs]defat_defeat(defeated):
+ """
+ Announces the defeat of a fighter in combat.
+
+ Args:
+ defeated (obj): Fighter that's been defeated.
+
+ Notes:
+ All this does is announce a defeat message by default, but if you
+ want anything else to happen to defeated fighters (like putting them
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!"%defeated)
+
+
+
[docs]defresolve_attack(
+ attacker,
+ defender,
+ attack_value=None,
+ defense_value=None,
+ damage_value=None,
+ inflict_condition=[],
+):
+ """
+ Resolves an attack and outputs the result.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Options:
+ attack_value (int): Override for attack roll
+ defense_value (int): Override for defense value
+ damage_value (int): Override for damage value
+ inflict_condition (list): Conditions to inflict upon hit, a
+ list of tuples formated as (condition(str), duration(int))
+
+ Notes:
+ This function is called by normal attacks as well as attacks
+ made with items.
+ """
+ # Get an attack roll from the attacker.
+ ifnotattack_value:
+ attack_value=get_attack(attacker,defender)
+ # Get a defense value from the defender.
+ ifnotdefense_value:
+ defense_value=get_defense(attacker,defender)
+ # If the attack value is lower than the defense value, miss. Otherwise, hit.
+ ifattack_value<defense_value:
+ attacker.location.msg_contents("%s's attack misses %s!"%(attacker,defender))
+ else:
+ ifnotdamage_value:
+ damage_value=get_damage(attacker,defender)# Calculate damage value.
+ # Announce damage dealt and apply damage.
+ attacker.location.msg_contents(
+ "%s hits %s for %i damage!"%(attacker,defender,damage_value)
+ )
+ apply_damage(defender,damage_value)
+ # Inflict conditions on hit, if any specified
+ forconditionininflict_condition:
+ add_condition(defender,attacker,condition[0],condition[1])
+ # If defender HP is reduced to 0 or less, call at_defeat.
+ ifdefender.db.hp<=0:
+ at_defeat(defender)
+
+
+
[docs]defcombat_cleanup(character):
+ """
+ Cleans up all the temporary combat-related attributes on a character.
+
+ Args:
+ character (obj): Character to have their combat attributes removed
+
+ Notes:
+ Any attribute whose key begins with 'combat_' is temporary and no
+ longer needed once a fight ends.
+ """
+ forattrincharacter.attributes.all():
+ ifattr.key[:7]=="combat_":# If the attribute name starts with 'combat_'...
+ character.attributes.remove(key=attr.key)# ...then delete it!
+
+
+
[docs]defis_in_combat(character):
+ """
+ Returns true if the given character is in combat.
+
+ Args:
+ character (obj): Character to determine if is in combat or not
+
+ Returns:
+ (bool): True if in combat or False if not in combat
+ """
+ returnbool(character.db.combat_turnhandler)
+
+
+
[docs]defis_turn(character):
+ """
+ Returns true if it's currently the given character's turn in combat.
+
+ Args:
+ character (obj): Character to determine if it is their turn or not
+
+ Returns:
+ (bool): True if it is their turn or False otherwise
+ """
+ turnhandler=character.db.combat_turnhandler
+ currentchar=turnhandler.db.fighters[turnhandler.db.turn]
+ returnbool(character==currentchar)
+
+
+
[docs]defspend_action(character,actions,action_name=None):
+ """
+ Spends a character's available combat actions and checks for end of turn.
+
+ Args:
+ character (obj): Character spending the action
+ actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
+
+ Keyword Args:
+ action_name (str or None): If a string is given, sets character's last action in
+ combat to provided string
+ """
+ ifaction_name:
+ character.db.combat_lastaction=action_name
+ ifactions=="all":# If spending all actions
+ character.db.combat_actionsleft=0# Set actions to 0
+ else:
+ character.db.combat_actionsleft-=actions# Use up actions.
+ ifcharacter.db.combat_actionsleft<0:
+ character.db.combat_actionsleft=0# Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character)# Signal potential end of turn.
+
+
+
[docs]defspend_item_use(item,user):
+ """
+ Spends one use on an item with limited uses.
+
+ Args:
+ item (obj): Item being used
+ user (obj): Character using the item
+
+ Notes:
+ If item.db.item_consumable is 'True', the item is destroyed if it
+ runs out of uses - if it's a string instead of 'True', it will also
+ spawn a new object as residue, using the value of item.db.item_consumable
+ as the name of the prototype to spawn.
+ """
+ item.db.item_uses-=1# Spend one use
+
+ ifitem.db.item_uses>0:# Has uses remaining
+ # Inform the player
+ user.msg("%s has %i uses remaining."%(item.key.capitalize(),item.db.item_uses))
+
+ else:# All uses spent
+
+ ifnotitem.db.item_consumable:# Item isn't consumable
+ # Just inform the player that the uses are gone
+ user.msg("%s has no uses remaining."%item.key.capitalize())
+
+ else:# If item is consumable
+ ifitem.db.item_consumable==True:# If the value is 'True', just destroy the item
+ user.msg("%s has been consumed."%item.key.capitalize())
+ item.delete()# Delete the spent item
+
+ else:# If a string, use value of item_consumable to spawn an object in its place
+ residue=spawn({"prototype":item.db.item_consumable})[0]# Spawn the residue
+ residue.location=item.location# Move the residue to the same place as the item
+ user.msg("After using %s, you are left with %s."%(item,residue))
+ item.delete()# Delete the spent item
+
+
+
[docs]defuse_item(user,item,target):
+ """
+ Performs the action of using an item.
+
+ Args:
+ user (obj): Character using the item
+ item (obj): Item being used
+ target (obj): Target of the item use
+ """
+ # If item is self only and no target given, set target to self.
+ ifitem.db.item_selfonlyandtarget==None:
+ target=user
+
+ # If item is self only, abort use if used on others.
+ ifitem.db.item_selfonlyanduser!=target:
+ user.msg("%s can only be used on yourself."%item)
+ return
+
+ # Set kwargs to pass to item_func
+ kwargs={}
+ ifitem.db.item_kwargs:
+ kwargs=item.db.item_kwargs
+
+ # Match item_func string to function
+ try:
+ item_func=ITEMFUNCS[item.db.item_func]
+ exceptKeyError:# If item_func string doesn't match to a function in ITEMFUNCS
+ user.msg("ERROR: %s not defined in ITEMFUNCS"%item.db.item_func)
+ return
+
+ # Call the item function - abort if it returns False, indicating an error.
+ # This performs the actual action of using the item.
+ # Regardless of what the function returns (if anything), it's still executed.
+ ifitem_func(item,user,target,**kwargs)==False:
+ return
+
+ # If we haven't returned yet, we assume the item was used successfully.
+ # Spend one use if item has limited uses
+ ifitem.db.item_uses:
+ spend_item_use(item,user)
+
+ # Spend an action if in combat
+ ifis_in_combat(user):
+ spend_action(user,1,action_name="item")
+
+
+
[docs]defcondition_tickdown(character,turnchar):
+ """
+ Ticks down the duration of conditions on a character at the start of a given character's turn.
+
+ Args:
+ character (obj): Character to tick down the conditions of
+ turnchar (obj): Character whose turn it currently is
+
+ Notes:
+ In combat, this is called on every fighter at the start of every character's turn. Out of
+ combat, it's instead called when a character's at_update() hook is called, which is every
+ 30 seconds by default.
+ """
+
+ forkeyincharacter.db.conditions:
+ # The first value is the remaining turns - the second value is whose turn to count down on.
+ condition_duration=character.db.conditions[key][0]
+ condition_turnchar=character.db.conditions[key][1]
+ # If the duration is 'True', then the condition doesn't tick down - it lasts indefinitely.
+ ifnotcondition_durationisTrue:
+ # Count down if the given turn character matches the condition's turn character.
+ ifcondition_turnchar==turnchar:
+ character.db.conditions[key][0]-=1
+ ifcharacter.db.conditions[key][0]<=0:
+ # If the duration is brought down to 0, remove the condition and inform everyone.
+ character.location.msg_contents(
+ "%s no longer has the '%s' condition."%(str(character),str(key))
+ )
+ delcharacter.db.conditions[key]
+
+
+
[docs]defadd_condition(character,turnchar,condition,duration):
+ """
+ Adds a condition to a fighter.
+
+ Args:
+ character (obj): Character to give the condition to
+ turnchar (obj): Character whose turn to tick down the condition on in combat
+ condition (str): Name of the condition
+ duration (int or True): Number of turns the condition lasts, or True for indefinite
+ """
+ # The first value is the remaining turns - the second value is whose turn to count down on.
+ character.db.conditions.update({condition:[duration,turnchar]})
+ # Tell everyone!
+ character.location.msg_contents("%s gains the '%s' condition."%(character,condition))
[docs]classTBItemsCharacter(DefaultCharacter):
+ """
+ A character able to participate in turn-based combat. Has attributes for current
+ and maximum HP, and access to combat commands.
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.max_hp=100# Set maximum HP to 100
+ self.db.hp=self.db.max_hp# Set current HP to maximum
+ self.db.conditions={}# Set empty dict for conditions
+ # Subscribe character to the ticker handler
+ tickerhandler.add(NONCOMBAT_TURN_TIME,self.at_update,idstring="update")
+ """
+ Adds attributes for a character's current and maximum HP.
+ We're just going to set this value at '100' by default.
+
+ An empty dictionary is created to store conditions later,
+ and the character is subscribed to the Ticker Handler, which
+ will call at_update() on the character, with the interval
+ specified by NONCOMBAT_TURN_TIME above. This is used to tick
+ down conditions out of combat.
+
+ You may want to expand this to include various 'stats' that
+ can be changed at creation and factor into combat calculations.
+ """
+
+
[docs]defat_before_move(self,destination):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # Keep the character from moving if at 0 HP or in combat.
+ ifis_in_combat(self):
+ self.msg("You can't exit a room while in combat!")
+ returnFalse# Returning false keeps the character from moving.
+ ifself.db.HP<=0:
+ self.msg("You can't move, you've been defeated!")
+ returnFalse
+ returnTrue
+
+
[docs]defat_turn_start(self):
+ """
+ Hook called at the beginning of this character's turn in combat.
+ """
+ # Prompt the character for their turn and give some information.
+ self.msg("|wIt's your turn! You have %i HP remaining.|n"%self.db.hp)
+
+ # Apply conditions that fire at the start of each turn.
+ self.apply_turn_conditions()
+
+
[docs]defapply_turn_conditions(self):
+ """
+ Applies the effect of conditions that occur at the start of each
+ turn in combat, or every 30 seconds out of combat.
+ """
+ # Regeneration: restores 4 to 8 HP at the start of character's turn
+ if"Regeneration"inself.db.conditions:
+ to_heal=randint(REGEN_RATE[0],REGEN_RAGE[1])# Restore HP
+ ifself.db.hp+to_heal>self.db.max_hp:
+ to_heal=self.db.max_hp-self.db.hp# Cap healing to max HP
+ self.db.hp+=to_heal
+ self.location.msg_contents("%s regains %i HP from Regeneration."%(self,to_heal))
+
+ # Poisoned: does 4 to 8 damage at the start of character's turn
+ if"Poisoned"inself.db.conditions:
+ to_hurt=randint(POISON_RATE[0],POISON_RATE[1])# Deal damage
+ apply_damage(self,to_hurt)
+ self.location.msg_contents("%s takes %i damage from being Poisoned."%(self,to_hurt))
+ ifself.db.hp<=0:
+ # Call at_defeat if poison defeats the character
+ at_defeat(self)
+
+ # Haste: Gain an extra action in combat.
+ ifis_in_combat(self)and"Haste"inself.db.conditions:
+ self.db.combat_actionsleft+=1
+ self.msg("You gain an extra action this turn from Haste!")
+
+ # Paralyzed: Have no actions in combat.
+ ifis_in_combat(self)and"Paralyzed"inself.db.conditions:
+ self.db.combat_actionsleft=0
+ self.location.msg_contents("%s is Paralyzed, and can't act this turn!"%self)
+ self.db.combat_turnhandler.turn_end_check(self)
+
+
[docs]defat_update(self):
+ """
+ Fires every 30 seconds.
+ """
+ ifnotis_in_combat(self):# Not in combat
+ # Change all conditions to update on character's turn.
+ forkeyinself.db.conditions:
+ self.db.conditions[key][1]=self
+ # Apply conditions that fire every turn
+ self.apply_turn_conditions()
+ # Tick down condition durations
+ condition_tickdown(self,self)
+
+
+
[docs]classTBItemsCharacterTest(TBItemsCharacter):
+ """
+ Just like the TBItemsCharacter, but doesn't subscribe to the TickerHandler.
+ This makes it easier to run unit tests on.
+ """
+
+
[docs]defat_object_creation(self):
+ self.db.max_hp=100# Set maximum HP to 100
+ self.db.hp=self.db.max_hp# Set current HP to maximum
+ self.db.conditions={}# Set empty dict for conditions
[docs]classTBItemsTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage' command.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key="Combat Turn Handler"
+ self.interval=5# Once every 5 seconds
+ self.persistent=True
+ self.db.fighters=[]
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ forthinginself.obj.contents:
+ ifthing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ forfighterinself.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler=self
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll=sorted(self.db.fighters,key=roll_init,reverse=True)
+ self.db.fighters=ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s "%", ".join(obj.keyforobjinself.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn=0
+ self.db.timer=TURN_TIMEOUT# Set timer to turn timeout specified in options
+
+
[docs]defat_stop(self):
+ """
+ Called at script termination.
+ """
+ forfighterinself.db.fighters:
+ combat_cleanup(fighter)# Clean up the combat attributes for every fighter.
+ self.obj.db.combat_turnhandler=None# Remove reference to turn handler in location
+
+
[docs]defat_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar=self.db.fighters[
+ self.db.turn
+ ]# Note the current character in the turn order.
+ self.db.timer-=self.interval# Count down the timer.
+
+ ifself.db.timer<=0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!"%currentchar)
+ spend_action(
+ currentchar,"all",action_name="disengage"
+ )# Spend all remaining actions.
+ return
+ elifself.db.timer<=10andnotself.db.timeout_warning_given:# 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given=True
+
+
[docs]definitialize_for_combat(self,character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ combat_cleanup(character)# Clean up leftover combat attributes beforehand, just in case.
+ character.db.combat_actionsleft=(
+ 0# Actions remaining - start of turn adds to this, turn ends when it reaches 0
+ )
+ character.db.combat_turnhandler=(
+ self# Add a reference to this turn handler script to the character
+ )
+ character.db.combat_lastaction="null"# Track last action taken in combat
+
+
[docs]defstart_turn(self,character):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ Here, you only get one action per turn, but you might want to allow more than
+ one per turn, or even grant a number of actions based on a character's
+ attributes. You can even add multiple different kinds of actions, I.E. actions
+ separated for movement, by adding "character.db.combat_movesleft = 3" or
+ something similar.
+ """
+ character.db.combat_actionsleft=ACTIONS_PER_TURN# Replenish actions
+ # Call character's at_turn_start() hook.
+ character.at_turn_start()
+
+
[docs]defnext_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check=True
+ forfighterinself.db.fighters:
+ if(
+ fighter.db.combat_lastaction!="disengage"
+ ):# If a character has done anything but disengage
+ disengage_check=False
+ ifdisengage_check:# All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters=0
+ forfighterinself.db.fighters:
+ iffighter.db.HP==0:
+ defeated_characters+=1# Add 1 for every fighter with 0 HP left (defeated)
+ ifdefeated_characters==(
+ len(self.db.fighters)-1
+ ):# If only one character isn't defeated
+ forfighterinself.db.fighters:
+ iffighter.db.HP!=0:
+ LastStanding=fighter# Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!"%LastStanding)
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar=self.db.fighters[self.db.turn]
+ self.db.turn+=1# Go to the next in the turn order.
+ ifself.db.turn>len(self.db.fighters)-1:
+ self.db.turn=0# Go back to the first in the turn order once you reach the end.
+
+ newchar=self.db.fighters[self.db.turn]# Note the new character
+
+ self.db.timer=TURN_TIMEOUT+self.time_until_next_repeat()# Reset the timer.
+ self.db.timeout_warning_given=False# Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!"%(currentchar,newchar))
+ self.start_turn(newchar)# Start the new character's turn.
+
+ # Count down condition timers.
+ forfighterinself.db.fighters:
+ condition_tickdown(fighter,newchar)
+
+
[docs]defturn_end_check(self,character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ ifnotcharacter.db.combat_actionsleft:# Character has no actions remaining
+ self.next_turn()
+ return
+
+
[docs]defjoin_fight(self,character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn,character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn+=1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
[docs]classCmdFight(Command):
+ """
+ Starts a fight with everyone in the same room as you.
+
+ Usage:
+ fight
+
+ When you start a fight, everyone in the room who is able to
+ fight is added to combat, and a turn order is randomly rolled.
+ When it's your turn, you can attack other characters.
+ """
+
+ key="fight"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ here=self.caller.location
+ fighters=[]
+
+ ifnotself.caller.db.hp:# If you don't have any hp
+ self.caller.msg("You can't start a fight if you've been defeated!")
+ return
+ ifis_in_combat(self.caller):# Already in a fight
+ self.caller.msg("You're already in a fight!")
+ return
+ forthinginhere.contents:# Test everything in the room to add it to the fight.
+ ifthing.db.HP:# If the object has HP...
+ fighters.append(thing)# ...then add it to the fight.
+ iflen(fighters)<=1:# If you're the only able fighter in the room
+ self.caller.msg("There's nobody here to fight!")
+ return
+ ifhere.db.combat_turnhandler:# If there's already a fight going on...
+ here.msg_contents("%s joins the fight!"%self.caller)
+ here.db.combat_turnhandler.join_fight(self.caller)# Join the fight!
+ return
+ here.msg_contents("%s starts a fight!"%self.caller)
+ # Add a turn handler script to the room, which starts combat.
+ here.scripts.add("contrib.turnbattle.tb_items.TBItemsTurnHandler")
+ # Remember you'll have to change the path to the script if you copy this code to your own modules!
+
+
+
[docs]classCmdAttack(Command):
+ """
+ Attacks another character.
+
+ Usage:
+ attack <target>
+
+ When in a fight, you may attack another character. The attack has
+ a chance to hit, and if successful, will deal damage.
+ """
+
+ key="attack"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ ifnotis_in_combat(self.caller):# If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ ifnotself.caller.db.hp:# Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ if"Frightened"inself.caller.db.conditions:# Can't attack if frightened
+ self.caller.msg("You're too frightened to attack!")
+ return
+
+ attacker=self.caller
+ defender=self.caller.search(self.args)
+
+ ifnotdefender:# No valid target given.
+ return
+
+ ifnotdefender.db.hp:# Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ ifattacker==defender:# Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker,defender)
+ spend_action(self.caller,1,action_name="attack")# Use up one action.
+
+
+
[docs]classCmdPass(Command):
+ """
+ Passes on your turn.
+
+ Usage:
+ pass
+
+ When in a fight, you can use this command to end your turn early, even
+ if there are still any actions you can take.
+ """
+
+ key="pass"
+ aliases=["wait","hold"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# Can only pass a turn in combat.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# Can only pass if it's your turn.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents(
+ "%s takes no further action, passing the turn."%self.caller
+ )
+ spend_action(self.caller,"all",action_name="pass")# Spend all remaining actions.
+
+
+
[docs]classCmdDisengage(Command):
+ """
+ Passes your turn and attempts to end combat.
+
+ Usage:
+ disengage
+
+ Ends your turn early and signals that you're trying to end
+ the fight. If all participants in a fight disengage, the
+ fight ends.
+ """
+
+ key="disengage"
+ aliases=["spare"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# If you're not in combat
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s disengages, ready to stop fighting."%self.caller)
+ spend_action(self.caller,"all",action_name="disengage")# Spend all remaining actions.
+ """
+ The action_name kwarg sets the character's last action to "disengage", which is checked by
+ the turn handler script to see if all fighters have disengaged.
+ """
+
+
+
[docs]classCmdRest(Command):
+ """
+ Recovers damage.
+
+ Usage:
+ rest
+
+ Resting recovers your HP to its maximum, but you can only
+ rest if you're not in a fight.
+ """
+
+ key="rest"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+
+ ifis_in_combat(self.caller):# If you're in combat
+ self.caller.msg("You can't rest while you're in combat.")
+ return
+
+ self.caller.db.hp=self.caller.db.max_hp# Set current HP to maximum
+ self.caller.location.msg_contents("%s rests to recover HP."%self.caller)
+ """
+ You'll probably want to replace this with your own system for recovering HP.
+ """
+
+
+
[docs]classCmdCombatHelp(CmdHelp):
+ """
+ View help or a list of topics
+
+ Usage:
+ help <topic or command>
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+
+ # Just like the default help command, but will give quick
+ # tips on combat when used in a fight with no arguments.
+
+
[docs]deffunc(self):
+ ifis_in_combat(self.caller)andnotself.args:# In combat and entered 'help' alone
+ self.caller.msg(
+ "Available combat commands:|/"
+ +"|wAttack:|n Attack a target, attempting to deal damage.|/"
+ +"|wPass:|n Pass your turn without further action.|/"
+ +"|wDisengage:|n End your turn and attempt to end combat.|/"
+ +"|wUse:|n Use an item you're carrying."
+ )
+ else:
+ super(CmdCombatHelp,self).func()# Call the default help command
+
+
+
[docs]classCmdUse(MuxCommand):
+ """
+ Use an item.
+
+ Usage:
+ use <item> [= target]
+
+ An item can have various function - looking at the item may
+ provide information as to its effects. Some items can be used
+ to attack others, and as such can only be used in combat.
+ """
+
+ key="use"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ # Search for item
+ item=self.caller.search(self.lhs,candidates=self.caller.contents)
+ ifnotitem:
+ return
+
+ # Search for target, if any is given
+ target=None
+ ifself.rhs:
+ target=self.caller.search(self.rhs)
+ ifnottarget:
+ return
+
+ # If in combat, can only use items on your turn
+ ifis_in_combat(self.caller):
+ ifnotis_turn(self.caller):
+ self.caller.msg("You can only use items on your turn.")
+ return
+
+ ifnotitem.db.item_func:# Object has no item_func, not usable
+ self.caller.msg("'%s' is not a usable item."%item.key.capitalize())
+ return
+
+ ifitem.attributes.has("item_uses"):# Item has limited uses
+ ifitem.db.item_uses<=0:# Limited uses are spent
+ self.caller.msg("'%s' has no uses remaining."%item.key.capitalize())
+ return
+
+ # If everything checks out, call the use_item function
+ use_item(self.caller,item,target)
+
+
+
[docs]classBattleCmdSet(default_cmds.CharacterCmdSet):
+ """
+ This command set includes all the commmands used in the battle system.
+ """
+
+ key="DefaultCharacter"
+
+
+
+
+"""
+----------------------------------------------------------------------------
+ITEM FUNCTIONS START HERE
+----------------------------------------------------------------------------
+
+These functions carry out the action of using an item - every item should
+contain a db entry "item_func", with its value being a string that is
+matched to one of these functions in the ITEMFUNCS dictionary below.
+
+Every item function must take the following arguments:
+ item (obj): The item being used
+ user (obj): The character using the item
+ target (obj): The target of the item use
+
+Item functions must also accept **kwargs - these keyword arguments can be
+used to define how different items that use the same function can have
+different effects (for example, different attack items doing different
+amounts of damage).
+
+Each function below contains a description of what kwargs the function will
+take and the effect they have on the result.
+"""
+
+
+
[docs]defitemfunc_heal(item,user,target,**kwargs):
+ """
+ Item function that heals HP.
+
+ kwargs:
+ min_healing(int): Minimum amount of HP recovered
+ max_healing(int): Maximum amount of HP recovered
+ """
+ ifnottarget:
+ target=user# Target user if none specified
+
+ ifnottarget.attributes.has("max_hp"):# Has no HP to speak of
+ user.msg("You can't use %s on that."%item)
+ returnFalse# Returning false aborts the item use
+
+ iftarget.db.hp>=target.db.max_hp:
+ user.msg("%s is already at full health."%target)
+ returnFalse
+
+ min_healing=20
+ max_healing=40
+
+ # Retrieve healing range from kwargs, if present
+ if"healing_range"inkwargs:
+ min_healing=kwargs["healing_range"][0]
+ max_healing=kwargs["healing_range"][1]
+
+ to_heal=randint(min_healing,max_healing)# Restore 20 to 40 hp
+ iftarget.db.hp+to_heal>target.db.max_hp:
+ to_heal=target.db.max_hp-target.db.hp# Cap healing to max HP
+ target.db.hp+=to_heal
+
+ user.location.msg_contents("%s uses %s! %s regains %i HP!"%(user,item,target,to_heal))
+
+
+
[docs]defitemfunc_add_condition(item,user,target,**kwargs):
+ """
+ Item function that gives the target one or more conditions.
+
+ kwargs:
+ conditions (list): Conditions added by the item
+ formatted as a list of tuples: (condition (str), duration (int or True))
+
+ Notes:
+ Should mostly be used for beneficial conditions - use itemfunc_attack
+ for an item that can give an enemy a harmful condition.
+ """
+ conditions=[("Regeneration",5)]
+
+ ifnottarget:
+ target=user# Target user if none specified
+
+ ifnottarget.attributes.has("max_hp"):# Is not a fighter
+ user.msg("You can't use %s on that."%item)
+ returnFalse# Returning false aborts the item use
+
+ # Retrieve condition / duration from kwargs, if present
+ if"conditions"inkwargs:
+ conditions=kwargs["conditions"]
+
+ user.location.msg_contents("%s uses %s!"%(user,item))
+
+ # Add conditions to the target
+ forconditioninconditions:
+ add_condition(target,user,condition[0],condition[1])
+
+
+
[docs]defitemfunc_cure_condition(item,user,target,**kwargs):
+ """
+ Item function that'll remove given conditions from a target.
+
+ kwargs:
+ to_cure(list): List of conditions (str) that the item cures when used
+ """
+ to_cure=["Poisoned"]
+
+ ifnottarget:
+ target=user# Target user if none specified
+
+ ifnottarget.attributes.has("max_hp"):# Is not a fighter
+ user.msg("You can't use %s on that."%item)
+ returnFalse# Returning false aborts the item use
+
+ # Retrieve condition(s) to cure from kwargs, if present
+ if"to_cure"inkwargs:
+ to_cure=kwargs["to_cure"]
+
+ item_msg="%s uses %s! "%(user,item)
+
+ forkeyintarget.db.conditions:
+ ifkeyinto_cure:
+ # If condition specified in to_cure, remove it.
+ item_msg+="%s no longer has the '%s' condition. "%(str(target),str(key))
+ deltarget.db.conditions[key]
+
+ user.location.msg_contents(item_msg)
+
+
+
[docs]defitemfunc_attack(item,user,target,**kwargs):
+ """
+ Item function that attacks a target.
+
+ kwargs:
+ min_damage(int): Minimum damage dealt by the attack
+ max_damage(int): Maximum damage dealth by the attack
+ accuracy(int): Bonus / penalty to attack accuracy roll
+ inflict_condition(list): List of conditions inflicted on hit,
+ formatted as a (str, int) tuple containing condition name
+ and duration.
+
+ Notes:
+ Calls resolve_attack at the end.
+ """
+ ifnotis_in_combat(user):
+ user.msg("You can only use that in combat.")
+ returnFalse# Returning false aborts the item use
+
+ ifnottarget:
+ user.msg("You have to specify a target to use %s! (use <item> = <target>)"%item)
+ returnFalse
+
+ iftarget==user:
+ user.msg("You can't attack yourself!")
+ returnFalse
+
+ ifnottarget.db.hp:# Has no HP
+ user.msg("You can't use %s on that."%item)
+ returnFalse
+
+ min_damage=20
+ max_damage=40
+ accuracy=0
+ inflict_condition=[]
+
+ # Retrieve values from kwargs, if present
+ if"damage_range"inkwargs:
+ min_damage=kwargs["damage_range"][0]
+ max_damage=kwargs["damage_range"][1]
+ if"accuracy"inkwargs:
+ accuracy=kwargs["accuracy"]
+ if"inflict_condition"inkwargs:
+ inflict_condition=kwargs["inflict_condition"]
+
+ # Roll attack and damage
+ attack_value=randint(1,100)+accuracy
+ damage_value=randint(min_damage,max_damage)
+
+ # Account for "Accuracy Up" and "Accuracy Down" conditions
+ if"Accuracy Up"inuser.db.conditions:
+ attack_value+=25
+ if"Accuracy Down"inuser.db.conditions:
+ attack_value-=25
+
+ user.location.msg_contents("%s attacks %s with %s!"%(user,target,item))
+ resolve_attack(
+ user,
+ target,
+ attack_value=attack_value,
+ damage_value=damage_value,
+ inflict_condition=inflict_condition,
+ )
+
+
+# Match strings to item functions here. We can't store callables on
+# prototypes, so we store a string instead, matching that string to
+# a callable in this dictionary.
+ITEMFUNCS={
+ "heal":itemfunc_heal,
+ "attack":itemfunc_attack,
+ "add_condition":itemfunc_add_condition,
+ "cure_condition":itemfunc_cure_condition,
+}
+
+"""
+----------------------------------------------------------------------------
+PROTOTYPES START HERE
+----------------------------------------------------------------------------
+
+You can paste these prototypes into your game's prototypes.py module in your
+/world/ folder, and use the spawner to create them - they serve as examples
+of items you can make and a handy way to demonstrate the system for
+conditions as well.
+
+Items don't have any particular typeclass - any object with a db entry
+"item_func" that references one of the functions given above can be used as
+an item with the 'use' command.
+
+Only "item_func" is required, but item behavior can be further modified by
+specifying any of the following:
+
+ item_uses (int): If defined, item has a limited number of uses
+
+ item_selfonly (bool): If True, user can only use the item on themself
+
+ item_consumable(True or str): If True, item is destroyed when it runs
+ out of uses. If a string is given, the item will spawn a new
+ object as it's destroyed, with the string specifying what prototype
+ to spawn.
+
+ item_kwargs (dict): Keyword arguments to pass to the function defined in
+ item_func. Unique to each function, and can be used to make multiple
+ items using the same function work differently.
+"""
+
+MEDKIT={
+ "key":"a medical kit",
+ "aliases":["medkit"],
+ "desc":"A standard medical kit. It can be used a few times to heal wounds.",
+ "item_func":"heal",
+ "item_uses":3,
+ "item_consumable":True,
+ "item_kwargs":{"healing_range":(15,25)},
+}
+
+GLASS_BOTTLE={"key":"a glass bottle","desc":"An empty glass bottle."}
+
+HEALTH_POTION={
+ "key":"a health potion",
+ "desc":"A glass bottle full of a mystical potion that heals wounds when used.",
+ "item_func":"heal",
+ "item_uses":1,
+ "item_consumable":"GLASS_BOTTLE",
+ "item_kwargs":{"healing_range":(35,50)},
+}
+
+REGEN_POTION={
+ "key":"a regeneration potion",
+ "desc":"A glass bottle full of a mystical potion that regenerates wounds over time.",
+ "item_func":"add_condition",
+ "item_uses":1,
+ "item_consumable":"GLASS_BOTTLE",
+ "item_kwargs":{"conditions":[("Regeneration",10)]},
+}
+
+HASTE_POTION={
+ "key":"a haste potion",
+ "desc":"A glass bottle full of a mystical potion that hastens its user.",
+ "item_func":"add_condition",
+ "item_uses":1,
+ "item_consumable":"GLASS_BOTTLE",
+ "item_kwargs":{"conditions":[("Haste",10)]},
+}
+
+BOMB={
+ "key":"a rotund bomb",
+ "desc":"A large black sphere with a fuse at the end. Can be used on enemies in combat.",
+ "item_func":"attack",
+ "item_uses":1,
+ "item_consumable":True,
+ "item_kwargs":{"damage_range":(25,40),"accuracy":25},
+}
+
+POISON_DART={
+ "key":"a poison dart",
+ "desc":"A thin dart coated in deadly poison. Can be used on enemies in combat",
+ "item_func":"attack",
+ "item_uses":1,
+ "item_consumable":True,
+ "item_kwargs":{
+ "damage_range":(5,10),
+ "accuracy":25,
+ "inflict_condition":[("Poisoned",10)],
+ },
+}
+
+TASER={
+ "key":"a taser",
+ "desc":"A device that can be used to paralyze enemies in combat.",
+ "item_func":"attack",
+ "item_kwargs":{
+ "damage_range":(10,20),
+ "accuracy":0,
+ "inflict_condition":[("Paralyzed",1)],
+ },
+}
+
+GHOST_GUN={
+ "key":"a ghost gun",
+ "desc":"A gun that fires scary ghosts at people. Anyone hit by a ghost becomes frightened.",
+ "item_func":"attack",
+ "item_uses":6,
+ "item_kwargs":{
+ "damage_range":(5,10),
+ "accuracy":15,
+ "inflict_condition":[("Frightened",1)],
+ },
+}
+
+ANTIDOTE_POTION={
+ "key":"an antidote potion",
+ "desc":"A glass bottle full of a mystical potion that cures poison when used.",
+ "item_func":"cure_condition",
+ "item_uses":1,
+ "item_consumable":"GLASS_BOTTLE",
+ "item_kwargs":{"to_cure":["Poisoned"]},
+}
+
+AMULET_OF_MIGHT={
+ "key":"The Amulet of Might",
+ "desc":"The one who holds this amulet can call upon its power to gain great strength.",
+ "item_func":"add_condition",
+ "item_selfonly":True,
+ "item_kwargs":{"conditions":[("Damage Up",3),("Accuracy Up",3),("Defense Up",3)]},
+}
+
+AMULET_OF_WEAKNESS={
+ "key":"The Amulet of Weakness",
+ "desc":"The one who holds this amulet can call upon its power to gain great weakness. It's not a terribly useful artifact.",
+ "item_func":"add_condition",
+ "item_selfonly":True,
+ "item_kwargs":{"conditions":[("Damage Down",3),("Accuracy Down",3),("Defense Down",3)]},
+}
+
Source code for evennia.contrib.turnbattle.tb_magic
+"""
+Simple turn-based combat system with spell casting
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' contrib that includes a basic,
+expandable framework for a 'magic system', whereby players can spend
+a limited resource (MP) to achieve a wide variety of effects, both in
+and out of combat. This does not have to strictly be a system for
+magic - it can easily be re-flavored to any other sort of resource
+based mechanic, like psionic powers, special moves and stamina, and
+so forth.
+
+In this system, spells are learned by name with the 'learnspell'
+command, and then used with the 'cast' command. Spells can be cast in or
+out of combat - some spells can only be cast in combat, some can only be
+cast outside of combat, and some can be cast any time. However, if you
+are in combat, you can only cast a spell on your turn, and doing so will
+typically use an action (as specified in the spell's funciton).
+
+Spells are defined at the end of the module in a database that's a
+dictionary of dictionaries - each spell is matched by name to a function,
+along with various parameters that restrict when the spell can be used and
+what the spell can be cast on. Included is a small variety of spells that
+damage opponents and heal HP, as well as one that creates an object.
+
+Because a spell can call any function, a spell can be made to do just
+about anything at all. The SPELLS dictionary at the bottom of the module
+even allows kwargs to be passed to the spell function, so that the same
+function can be re-used for multiple similar spells.
+
+Spells in this system work on a very basic resource: MP, which is spent
+when casting spells and restored by resting. It shouldn't be too difficult
+to modify this system to use spell slots, some physical fuel or resource,
+or whatever else your game requires.
+
+To install and test, import this module's TBMagicCharacter object into
+your game's character.py module:
+
+ from evennia.contrib.turnbattle.tb_magic import TBMagicCharacter
+
+And change your game's character typeclass to inherit from TBMagicCharacter
+instead of the default:
+
+ class Character(TBMagicCharacter):
+
+Note: If your character already existed you need to also make sure
+to re-run the creation hooks on it to set the needed Attributes.
+Use `update self` to try on yourself or use py to call `at_object_creation()`
+on all existing Characters.
+
+
+Next, import this module into your default_cmdsets.py module:
+
+ from evennia.contrib.turnbattle import tb_magic
+
+And add the battle command set to your default command set:
+
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(tb_magic.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+fromrandomimportrandint
+fromevenniaimportDefaultCharacter,Command,default_cmds,DefaultScript,create_object
+fromevennia.commands.default.muxcommandimportMuxCommand
+fromevennia.commands.default.helpimportCmdHelp
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT=30# Time before turns automatically end, in seconds
+ACTIONS_PER_TURN=1# Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+
[docs]defroll_init(character):
+ """
+ Rolls a number between 1-1000 to determine initiative.
+
+ Args:
+ character (obj): The character to determine initiative for
+
+ Returns:
+ initiative (int): The character's place in initiative - higher
+ numbers go first.
+
+ Notes:
+ By default, does not reference the character and simply returns
+ a random integer from 1 to 1000.
+
+ Since the character is passed to this function, you can easily reference
+ a character's stats to determine an initiative roll - for example, if your
+ character has a 'dexterity' attribute, you can use it to give that character
+ an advantage in turn order, like so:
+
+ return (randint(1,20)) + character.db.dexterity
+
+ This way, characters with a higher dexterity will go first more often.
+ """
+ returnrandint(1,1000)
+
+
+
[docs]defget_attack(attacker,defender):
+ """
+ Returns a value for an attack roll.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ attack_value (int): Attack roll value, compared against a defense value
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, returns a random integer from 1 to 100 without using any
+ properties from either the attacker or defender.
+
+ This can easily be expanded to return a value based on characters stats,
+ equipment, and abilities. This is why the attacker and defender are passed
+ to this function, even though nothing from either one are used in this example.
+ """
+ # For this example, just return a random integer up to 100.
+ attack_value=randint(1,100)
+ returnattack_value
+
+
+
[docs]defget_defense(attacker,defender):
+ """
+ Returns a value for defense, which an attack roll must equal or exceed in order
+ for an attack to hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ defense_value (int): Defense value, compared against an attack roll
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, returns 50, not taking any properties of the defender or
+ attacker into account.
+
+ As above, this can be expanded upon based on character stats and equipment.
+ """
+ # For this example, just return 50, for about a 50/50 chance of hit.
+ defense_value=50
+ returndefense_value
+
+
+
[docs]defget_damage(attacker,defender):
+ """
+ Returns a value for damage to be deducted from the defender's HP after abilities
+ successful hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being damaged
+
+ Returns:
+ damage_value (int): Damage value, which is to be deducted from the defending
+ character's HP.
+
+ Notes:
+ By default, returns a random integer from 15 to 25 without using any
+ properties from either the attacker or defender.
+
+ Again, this can be expanded upon.
+ """
+ # For this example, just generate a number between 15 and 25.
+ damage_value=randint(15,25)
+ returndamage_value
+
+
+
[docs]defapply_damage(defender,damage):
+ """
+ Applies damage to a target, reducing their HP by the damage amount to a
+ minimum of 0.
+
+ Args:
+ defender (obj): Character taking damage
+ damage (int): Amount of damage being taken
+ """
+ defender.db.hp-=damage# Reduce defender's HP by the damage dealt.
+ # If this reduces it to 0 or less, set HP to 0.
+ ifdefender.db.hp<=0:
+ defender.db.hp=0
+
+
+
[docs]defat_defeat(defeated):
+ """
+ Announces the defeat of a fighter in combat.
+
+ Args:
+ defeated (obj): Fighter that's been defeated.
+
+ Notes:
+ All this does is announce a defeat message by default, but if you
+ want anything else to happen to defeated fighters (like putting them
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!"%defeated)
+
+
+
[docs]defresolve_attack(attacker,defender,attack_value=None,defense_value=None):
+ """
+ Resolves an attack and outputs the result.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Notes:
+ Even though the attack and defense values are calculated
+ extremely simply, they are separated out into their own functions
+ so that they are easier to expand upon.
+ """
+ # Get an attack roll from the attacker.
+ ifnotattack_value:
+ attack_value=get_attack(attacker,defender)
+ # Get a defense value from the defender.
+ ifnotdefense_value:
+ defense_value=get_defense(attacker,defender)
+ # If the attack value is lower than the defense value, miss. Otherwise, hit.
+ ifattack_value<defense_value:
+ attacker.location.msg_contents("%s's attack misses %s!"%(attacker,defender))
+ else:
+ damage_value=get_damage(attacker,defender)# Calculate damage value.
+ # Announce damage dealt and apply damage.
+ attacker.location.msg_contents(
+ "%s hits %s for %i damage!"%(attacker,defender,damage_value)
+ )
+ apply_damage(defender,damage_value)
+ # If defender HP is reduced to 0 or less, call at_defeat.
+ ifdefender.db.hp<=0:
+ at_defeat(defender)
+
+
+
[docs]defcombat_cleanup(character):
+ """
+ Cleans up all the temporary combat-related attributes on a character.
+
+ Args:
+ character (obj): Character to have their combat attributes removed
+
+ Notes:
+ Any attribute whose key begins with 'combat_' is temporary and no
+ longer needed once a fight ends.
+ """
+ forattrincharacter.attributes.all():
+ ifattr.key[:7]=="combat_":# If the attribute name starts with 'combat_'...
+ character.attributes.remove(key=attr.key)# ...then delete it!
+
+
+
[docs]defis_in_combat(character):
+ """
+ Returns true if the given character is in combat.
+
+ Args:
+ character (obj): Character to determine if is in combat or not
+
+ Returns:
+ (bool): True if in combat or False if not in combat
+ """
+ returnbool(character.db.combat_turnhandler)
+
+
+
[docs]defis_turn(character):
+ """
+ Returns true if it's currently the given character's turn in combat.
+
+ Args:
+ character (obj): Character to determine if it is their turn or not
+
+ Returns:
+ (bool): True if it is their turn or False otherwise
+ """
+ turnhandler=character.db.combat_turnhandler
+ currentchar=turnhandler.db.fighters[turnhandler.db.turn]
+ returnbool(character==currentchar)
+
+
+
[docs]defspend_action(character,actions,action_name=None):
+ """
+ Spends a character's available combat actions and checks for end of turn.
+
+ Args:
+ character (obj): Character spending the action
+ actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
+
+ Keyword Args:
+ action_name (str or None): If a string is given, sets character's last action in
+ combat to provided string
+ """
+ ifnotis_in_combat(character):
+ return
+ ifaction_name:
+ character.db.combat_lastaction=action_name
+ ifactions=="all":# If spending all actions
+ character.db.combat_actionsleft=0# Set actions to 0
+ else:
+ character.db.combat_actionsleft-=actions# Use up actions.
+ ifcharacter.db.combat_actionsleft<0:
+ character.db.combat_actionsleft=0# Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character)# Signal potential end of turn.
[docs]classTBMagicCharacter(DefaultCharacter):
+ """
+ A character able to participate in turn-based combat. Has attributes for current
+ and maximum HP, and access to combat commands.
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+
+ Adds attributes for a character's current and maximum HP.
+ We're just going to set this value at '100' by default.
+
+ You may want to expand this to include various 'stats' that
+ can be changed at creation and factor into combat calculations.
+ """
+ self.db.max_hp=100# Set maximum HP to 100
+ self.db.hp=self.db.max_hp# Set current HP to maximum
+ self.db.spells_known=[]# Set empty spells known list
+ self.db.max_mp=20# Set maximum MP to 20
+ self.db.mp=self.db.max_mp# Set current MP to maximum
+
+
[docs]defat_before_move(self,destination):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # Keep the character from moving if at 0 HP or in combat.
+ ifis_in_combat(self):
+ self.msg("You can't exit a room while in combat!")
+ returnFalse# Returning false keeps the character from moving.
+ ifself.db.HP<=0:
+ self.msg("You can't move, you've been defeated!")
+ returnFalse
+ returnTrue
[docs]classTBMagicTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage' command.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key="Combat Turn Handler"
+ self.interval=5# Once every 5 seconds
+ self.persistent=True
+ self.db.fighters=[]
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ forthinginself.obj.contents:
+ ifthing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ forfighterinself.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler=self
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll=sorted(self.db.fighters,key=roll_init,reverse=True)
+ self.db.fighters=ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s "%", ".join(obj.keyforobjinself.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn=0
+ self.db.timer=TURN_TIMEOUT# Set timer to turn timeout specified in options
+
+
[docs]defat_stop(self):
+ """
+ Called at script termination.
+ """
+ forfighterinself.db.fighters:
+ combat_cleanup(fighter)# Clean up the combat attributes for every fighter.
+ self.obj.db.combat_turnhandler=None# Remove reference to turn handler in location
+
+
[docs]defat_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar=self.db.fighters[
+ self.db.turn
+ ]# Note the current character in the turn order.
+ self.db.timer-=self.interval# Count down the timer.
+
+ ifself.db.timer<=0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!"%currentchar)
+ spend_action(
+ currentchar,"all",action_name="disengage"
+ )# Spend all remaining actions.
+ return
+ elifself.db.timer<=10andnotself.db.timeout_warning_given:# 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given=True
+
+
[docs]definitialize_for_combat(self,character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ combat_cleanup(character)# Clean up leftover combat attributes beforehand, just in case.
+ character.db.combat_actionsleft=(
+ 0# Actions remaining - start of turn adds to this, turn ends when it reaches 0
+ )
+ character.db.combat_turnhandler=(
+ self# Add a reference to this turn handler script to the character
+ )
+ character.db.combat_lastaction="null"# Track last action taken in combat
+
+
[docs]defstart_turn(self,character):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ Here, you only get one action per turn, but you might want to allow more than
+ one per turn, or even grant a number of actions based on a character's
+ attributes. You can even add multiple different kinds of actions, I.E. actions
+ separated for movement, by adding "character.db.combat_movesleft = 3" or
+ something similar.
+ """
+ character.db.combat_actionsleft=ACTIONS_PER_TURN# Replenish actions
+ # Prompt the character for their turn and give some information.
+ character.msg("|wIt's your turn! You have %i HP remaining.|n"%character.db.hp)
+
+
[docs]defnext_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check=True
+ forfighterinself.db.fighters:
+ if(
+ fighter.db.combat_lastaction!="disengage"
+ ):# If a character has done anything but disengage
+ disengage_check=False
+ ifdisengage_check:# All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters=0
+ forfighterinself.db.fighters:
+ iffighter.db.HP==0:
+ defeated_characters+=1# Add 1 for every fighter with 0 HP left (defeated)
+ ifdefeated_characters==(
+ len(self.db.fighters)-1
+ ):# If only one character isn't defeated
+ forfighterinself.db.fighters:
+ iffighter.db.HP!=0:
+ LastStanding=fighter# Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!"%LastStanding)
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar=self.db.fighters[self.db.turn]
+ self.db.turn+=1# Go to the next in the turn order.
+ ifself.db.turn>len(self.db.fighters)-1:
+ self.db.turn=0# Go back to the first in the turn order once you reach the end.
+ newchar=self.db.fighters[self.db.turn]# Note the new character
+ self.db.timer=TURN_TIMEOUT+self.time_until_next_repeat()# Reset the timer.
+ self.db.timeout_warning_given=False# Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!"%(currentchar,newchar))
+ self.start_turn(newchar)# Start the new character's turn.
+
+
[docs]defturn_end_check(self,character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ ifnotcharacter.db.combat_actionsleft:# Character has no actions remaining
+ self.next_turn()
+ return
+
+
[docs]defjoin_fight(self,character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn,character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn+=1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
[docs]classCmdFight(Command):
+ """
+ Starts a fight with everyone in the same room as you.
+
+ Usage:
+ fight
+
+ When you start a fight, everyone in the room who is able to
+ fight is added to combat, and a turn order is randomly rolled.
+ When it's your turn, you can attack other characters.
+ """
+
+ key="fight"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ here=self.caller.location
+ fighters=[]
+
+ ifnotself.caller.db.hp:# If you don't have any hp
+ self.caller.msg("You can't start a fight if you've been defeated!")
+ return
+ ifis_in_combat(self.caller):# Already in a fight
+ self.caller.msg("You're already in a fight!")
+ return
+ forthinginhere.contents:# Test everything in the room to add it to the fight.
+ ifthing.db.HP:# If the object has HP...
+ fighters.append(thing)# ...then add it to the fight.
+ iflen(fighters)<=1:# If you're the only able fighter in the room
+ self.caller.msg("There's nobody here to fight!")
+ return
+ ifhere.db.combat_turnhandler:# If there's already a fight going on...
+ here.msg_contents("%s joins the fight!"%self.caller)
+ here.db.combat_turnhandler.join_fight(self.caller)# Join the fight!
+ return
+ here.msg_contents("%s starts a fight!"%self.caller)
+ # Add a turn handler script to the room, which starts combat.
+ here.scripts.add("contrib.turnbattle.tb_magic.TBMagicTurnHandler")
+ # Remember you'll have to change the path to the script if you copy this code to your own modules!
+
+
+
[docs]classCmdAttack(Command):
+ """
+ Attacks another character.
+
+ Usage:
+ attack <target>
+
+ When in a fight, you may attack another character. The attack has
+ a chance to hit, and if successful, will deal damage.
+ """
+
+ key="attack"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ ifnotis_in_combat(self.caller):# If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ ifnotself.caller.db.hp:# Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ attacker=self.caller
+ defender=self.caller.search(self.args)
+
+ ifnotdefender:# No valid target given.
+ return
+
+ ifnotdefender.db.hp:# Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ ifattacker==defender:# Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker,defender)
+ spend_action(self.caller,1,action_name="attack")# Use up one action.
+
+
+
[docs]classCmdPass(Command):
+ """
+ Passes on your turn.
+
+ Usage:
+ pass
+
+ When in a fight, you can use this command to end your turn early, even
+ if there are still any actions you can take.
+ """
+
+ key="pass"
+ aliases=["wait","hold"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# Can only pass a turn in combat.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# Can only pass if it's your turn.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents(
+ "%s takes no further action, passing the turn."%self.caller
+ )
+ spend_action(self.caller,"all",action_name="pass")# Spend all remaining actions.
+
+
+
[docs]classCmdDisengage(Command):
+ """
+ Passes your turn and attempts to end combat.
+
+ Usage:
+ disengage
+
+ Ends your turn early and signals that you're trying to end
+ the fight. If all participants in a fight disengage, the
+ fight ends.
+ """
+
+ key="disengage"
+ aliases=["spare"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# If you're not in combat
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s disengages, ready to stop fighting."%self.caller)
+ spend_action(self.caller,"all",action_name="disengage")# Spend all remaining actions.
+ """
+ The action_name kwarg sets the character's last action to "disengage", which is checked by
+ the turn handler script to see if all fighters have disengaged.
+ """
+
+
+
[docs]classCmdLearnSpell(Command):
+ """
+ Learn a magic spell.
+
+ Usage:
+ learnspell <spell name>
+
+ Adds a spell by name to your list of spells known.
+
+ The following spells are provided as examples:
+
+ |wmagic missile|n (3 MP): Fires three missiles that never miss. Can target
+ up to three different enemies.
+
+ |wflame shot|n (3 MP): Shoots a high-damage jet of flame at one target.
+
+ |wcure wounds|n (5 MP): Heals damage on one target.
+
+ |wmass cure wounds|n (10 MP): Like 'cure wounds', but can heal up to 5
+ targets at once.
+
+ |wfull heal|n (12 MP): Heals one target back to full HP.
+
+ |wcactus conjuration|n (2 MP): Creates a cactus.
+ """
+
+ key="learnspell"
+ help_category="magic"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ spell_list=sorted(SPELLS.keys())
+ args=self.args.lower()
+ args=args.strip(" ")
+ caller=self.caller
+ spell_to_learn=[]
+
+ ifnotargsorlen(args)<3:# No spell given
+ caller.msg("Usage: learnspell <spell name>")
+ return
+
+ forspellinspell_list:# Match inputs to spells
+ ifargsinspell.lower():
+ spell_to_learn.append(spell)
+
+ ifspell_to_learn==[]:# No spells matched
+ caller.msg("There is no spell with that name.")
+ return
+ iflen(spell_to_learn)>1:# More than one match
+ matched_spells=", ".join(spell_to_learn)
+ caller.msg("Which spell do you mean: %s?"%matched_spells)
+ return
+
+ iflen(spell_to_learn)==1:# If one match, extract the string
+ spell_to_learn=spell_to_learn[0]
+
+ ifspell_to_learnnotinself.caller.db.spells_known:# If the spell isn't known...
+ caller.db.spells_known.append(spell_to_learn)# ...then add the spell to the character
+ caller.msg("You learn the spell '%s'!"%spell_to_learn)
+ return
+ ifspell_to_learninself.caller.db.spells_known:# Already has the spell specified
+ caller.msg("You already know the spell '%s'!"%spell_to_learn)
+ """
+ You will almost definitely want to replace this with your own system
+ for learning spells, perhaps tied to character advancement or finding
+ items in the game world that spells can be learned from.
+ """
+
+
+
[docs]classCmdCast(MuxCommand):
+ """
+ Cast a magic spell that you know, provided you have the MP
+ to spend on its casting.
+
+ Usage:
+ cast <spellname> [= <target1>, <target2>, etc...]
+
+ Some spells can be cast on multiple targets, some can be cast
+ on only yourself, and some don't need a target specified at all.
+ Typing 'cast' by itself will give you a list of spells you know.
+ """
+
+ key="cast"
+ help_category="magic"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+
+ Note: This is a quite long command, since it has to cope with all
+ the different circumstances in which you may or may not be able
+ to cast a spell. None of the spell's effects are handled by the
+ command - all the command does is verify that the player's input
+ is valid for the spell being cast and then call the spell's
+ function.
+ """
+ caller=self.caller
+
+ ifnotself.lhsorlen(self.lhs)<3:# No spell name given
+ caller.msg("Usage: cast <spell name> = <target>, <target2>, ...")
+ ifnotcaller.db.spells_known:
+ caller.msg("You don't know any spells.")
+ return
+ else:
+ caller.db.spells_known=sorted(caller.db.spells_known)
+ spells_known_msg="You know the following spells:|/"+"|/".join(
+ caller.db.spells_known
+ )
+ caller.msg(spells_known_msg)# List the spells the player knows
+ return
+
+ spellname=self.lhs.lower()
+ spell_to_cast=[]
+ spell_targets=[]
+
+ ifnotself.rhs:
+ spell_targets=[]
+ elifself.rhs.lower()in["me","self","myself"]:
+ spell_targets=[caller]
+ eliflen(self.rhs)>2:
+ spell_targets=self.rhslist
+
+ forspellincaller.db.spells_known:# Match inputs to spells
+ ifself.lhsinspell.lower():
+ spell_to_cast.append(spell)
+
+ ifspell_to_cast==[]:# No spells matched
+ caller.msg("You don't know a spell of that name.")
+ return
+ iflen(spell_to_cast)>1:# More than one match
+ matched_spells=", ".join(spell_to_cast)
+ caller.msg("Which spell do you mean: %s?"%matched_spells)
+ return
+
+ iflen(spell_to_cast)==1:# If one match, extract the string
+ spell_to_cast=spell_to_cast[0]
+
+ ifspell_to_castnotinSPELLS:# Spell isn't defined
+ caller.msg("ERROR: Spell %s is undefined"%spell_to_cast)
+ return
+
+ # Time to extract some info from the chosen spell!
+ spelldata=SPELLS[spell_to_cast]
+
+ # Add in some default data if optional parameters aren't specified
+ if"combat_spell"notinspelldata:
+ spelldata.update({"combat_spell":True})
+ if"noncombat_spell"notinspelldata:
+ spelldata.update({"noncombat_spell":True})
+ if"max_targets"notinspelldata:
+ spelldata.update({"max_targets":1})
+
+ # Store any superfluous options as kwargs to pass to the spell function
+ kwargs={}
+ spelldata_opts=[
+ "spellfunc",
+ "target",
+ "cost",
+ "combat_spell",
+ "noncombat_spell",
+ "max_targets",
+ ]
+ forkeyinspelldata:
+ ifkeynotinspelldata_opts:
+ kwargs.update({key:spelldata[key]})
+
+ # If caster doesn't have enough MP to cover the spell's cost, give error and return
+ ifspelldata["cost"]>caller.db.mp:
+ caller.msg("You don't have enough MP to cast '%s'."%spell_to_cast)
+ return
+
+ # If in combat and the spell isn't a combat spell, give error message and return
+ ifspelldata["combat_spell"]==Falseandis_in_combat(caller):
+ caller.msg("You can't use the spell '%s' in combat."%spell_to_cast)
+ return
+
+ # If not in combat and the spell isn't a non-combat spell, error ms and return.
+ ifspelldata["noncombat_spell"]==Falseandis_in_combat(caller)==False:
+ caller.msg("You can't use the spell '%s' outside of combat."%spell_to_cast)
+ return
+
+ # If spell takes no targets and one is given, give error message and return
+ iflen(spell_targets)>0andspelldata["target"]=="none":
+ caller.msg("The spell '%s' isn't cast on a target."%spell_to_cast)
+ return
+
+ # If no target is given and spell requires a target, give error message
+ ifspelldata["target"]notin["self","none"]:
+ iflen(spell_targets)==0:
+ caller.msg("The spell '%s' requires a target."%spell_to_cast)
+ return
+
+ # If more targets given than maximum, give error message
+ iflen(spell_targets)>spelldata["max_targets"]:
+ targplural="target"
+ ifspelldata["max_targets"]>1:
+ targplural="targets"
+ caller.msg(
+ "The spell '%s' can only be cast on %i%s."
+ %(spell_to_cast,spelldata["max_targets"],targplural)
+ )
+ return
+
+ # Set up our candidates for targets
+ target_candidates=[]
+
+ # If spell targets 'any' or 'other', any object in caster's inventory or location
+ # can be targeted by the spell.
+ ifspelldata["target"]in["any","other"]:
+ target_candidates=caller.location.contents+caller.contents
+
+ # If spell targets 'anyobj', only non-character objects can be targeted.
+ ifspelldata["target"]=="anyobj":
+ prefilter_candidates=caller.location.contents+caller.contents
+ forthinginprefilter_candidates:
+ ifnotthing.attributes.has("max_hp"):# Has no max HP, isn't a fighter
+ target_candidates.append(thing)
+
+ # If spell targets 'anychar' or 'otherchar', only characters can be targeted.
+ ifspelldata["target"]in["anychar","otherchar"]:
+ prefilter_candidates=caller.location.contents
+ forthinginprefilter_candidates:
+ ifthing.attributes.has("max_hp"):# Has max HP, is a fighter
+ target_candidates.append(thing)
+
+ # Now, match each entry in spell_targets to an object in the search candidates
+ matched_targets=[]
+ fortargetinspell_targets:
+ match=caller.search(target,candidates=target_candidates)
+ matched_targets.append(match)
+ spell_targets=matched_targets
+
+ # If no target is given and the spell's target is 'self', set target to self
+ iflen(spell_targets)==0andspelldata["target"]=="self":
+ spell_targets=[caller]
+
+ # Give error message if trying to cast an "other" target spell on yourself
+ ifspelldata["target"]in["other","otherchar"]:
+ ifcallerinspell_targets:
+ caller.msg("You can't cast '%s' on yourself."%spell_to_cast)
+ return
+
+ # Return if "None" in target list, indicating failed match
+ ifNoneinspell_targets:
+ # No need to give an error message, as 'search' gives one by default.
+ return
+
+ # Give error message if repeats in target list
+ iflen(spell_targets)!=len(set(spell_targets)):
+ caller.msg("You can't specify the same target more than once!")
+ return
+
+ # Finally, we can cast the spell itself. Note that MP is not deducted here!
+ try:
+ spelldata["spellfunc"](
+ caller,spell_to_cast,spell_targets,spelldata["cost"],**kwargs
+ )
+ exceptException:
+ log_trace("Error in callback for spell: %s."%spell_to_cast)
+
+
+
[docs]classCmdRest(Command):
+ """
+ Recovers damage and restores MP.
+
+ Usage:
+ rest
+
+ Resting recovers your HP and MP to their maximum, but you can
+ only rest if you're not in a fight.
+ """
+
+ key="rest"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+
+ ifis_in_combat(self.caller):# If you're in combat
+ self.caller.msg("You can't rest while you're in combat.")
+ return
+
+ self.caller.db.hp=self.caller.db.max_hp# Set current HP to maximum
+ self.caller.db.mp=self.caller.db.max_mp# Set current MP to maximum
+ self.caller.location.msg_contents("%s rests to recover HP and MP."%self.caller)
+ # You'll probably want to replace this with your own system for recovering HP and MP.
+
+
+
[docs]classCmdStatus(Command):
+ """
+ Gives combat information.
+
+ Usage:
+ status
+
+ Shows your current and maximum HP and your distance from
+ other targets in combat.
+ """
+
+ key="status"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+ char=self.caller
+
+ ifnotchar.db.max_hp:# Character not initialized, IE in unit tests
+ char.db.hp=100
+ char.db.max_hp=100
+ char.db.spells_known=[]
+ char.db.max_mp=20
+ char.db.mp=char.db.max_mp
+
+ char.msg(
+ "You have %i / %i HP and %i / %i MP."
+ %(char.db.hp,char.db.max_hp,char.db.mp,char.db.max_mp)
+ )
+
+
+
[docs]classCmdCombatHelp(CmdHelp):
+ """
+ View help or a list of topics
+
+ Usage:
+ help <topic or command>
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+
+ # Just like the default help command, but will give quick
+ # tips on combat when used in a fight with no arguments.
+
+
[docs]deffunc(self):
+ ifis_in_combat(self.caller)andnotself.args:# In combat and entered 'help' alone
+ self.caller.msg(
+ "Available combat commands:|/"
+ +"|wAttack:|n Attack a target, attempting to deal damage.|/"
+ +"|wPass:|n Pass your turn without further action.|/"
+ +"|wDisengage:|n End your turn and attempt to end combat.|/"
+ )
+ else:
+ super(CmdCombatHelp,self).func()# Call the default help command
+
+
+
[docs]classBattleCmdSet(default_cmds.CharacterCmdSet):
+ """
+ This command set includes all the commmands used in the battle system.
+ """
+
+ key="DefaultCharacter"
+
+
+
+
+"""
+----------------------------------------------------------------------------
+SPELL FUNCTIONS START HERE
+----------------------------------------------------------------------------
+
+These are the functions that are called by the 'cast' command to perform the
+effects of various spells. Which spells execute which functions and what
+parameters are passed to them are specified at the bottom of the module, in
+the 'SPELLS' dictionary.
+
+All of these functions take the same arguments:
+ caster (obj): Character casting the spell
+ spell_name (str): Name of the spell being cast
+ targets (list): List of objects targeted by the spell
+ cost (int): MP cost of casting the spell
+
+These functions also all accept **kwargs, and how these are used is specified
+in the docstring for each function.
+"""
+
+
+
[docs]defspell_healing(caster,spell_name,targets,cost,**kwargs):
+ """
+ Spell that restores HP to a target or targets.
+
+ kwargs:
+ healing_range (tuple): Minimum and maximum amount healed to
+ each target. (20, 40) by default.
+ """
+ spell_msg="%s casts %s!"%(caster,spell_name)
+
+ min_healing=20
+ max_healing=40
+
+ # Retrieve healing range from kwargs, if present
+ if"healing_range"inkwargs:
+ min_healing=kwargs["healing_range"][0]
+ max_healing=kwargs["healing_range"][1]
+
+ forcharacterintargets:
+ to_heal=randint(min_healing,max_healing)# Restore 20 to 40 hp
+ ifcharacter.db.hp+to_heal>character.db.max_hp:
+ to_heal=character.db.max_hp-character.db.hp# Cap healing to max HP
+ character.db.hp+=to_heal
+ spell_msg+=" %s regains %i HP!"%(character,to_heal)
+
+ caster.db.mp-=cost# Deduct MP cost
+
+ caster.location.msg_contents(spell_msg)# Message the room with spell results
+
+ ifis_in_combat(caster):# Spend action if in combat
+ spend_action(caster,1,action_name="cast")
+
+
+
[docs]defspell_attack(caster,spell_name,targets,cost,**kwargs):
+ """
+ Spell that deals damage in combat. Similar to resolve_attack.
+
+ kwargs:
+ attack_name (tuple): Single and plural describing the sort of
+ attack or projectile that strikes each enemy.
+ damage_range (tuple): Minimum and maximum damage dealt by the
+ spell. (10, 20) by default.
+ accuracy (int): Modifier to the spell's attack roll, determining
+ an increased or decreased chance to hit. 0 by default.
+ attack_count (int): How many individual attacks are made as part
+ of the spell. If the number of attacks exceeds the number of
+ targets, the first target specified will be attacked more
+ than once. Just 1 by default - if the attack_count is less
+ than the number targets given, each target will only be
+ attacked once.
+ """
+ spell_msg="%s casts %s!"%(caster,spell_name)
+
+ atkname_single="The spell"
+ atkname_plural="spells"
+ min_damage=10
+ max_damage=20
+ accuracy=0
+ attack_count=1
+
+ # Retrieve some variables from kwargs, if present
+ if"attack_name"inkwargs:
+ atkname_single=kwargs["attack_name"][0]
+ atkname_plural=kwargs["attack_name"][1]
+ if"damage_range"inkwargs:
+ min_damage=kwargs["damage_range"][0]
+ max_damage=kwargs["damage_range"][1]
+ if"accuracy"inkwargs:
+ accuracy=kwargs["accuracy"]
+ if"attack_count"inkwargs:
+ attack_count=kwargs["attack_count"]
+
+ to_attack=[]
+ # If there are more attacks than targets given, attack first target multiple times
+ iflen(targets)<attack_count:
+ to_attack=to_attack+targets
+ extra_attacks=attack_count-len(targets)
+ forninrange(extra_attacks):
+ to_attack.insert(0,targets[0])
+ else:
+ to_attack=to_attack+targets
+
+ # Set up dictionaries to track number of hits and total damage
+ total_hits={}
+ total_damage={}
+ forfighterintargets:
+ total_hits.update({fighter:0})
+ total_damage.update({fighter:0})
+
+ # Resolve attack for each target
+ forfighterinto_attack:
+ attack_value=randint(1,100)+accuracy# Spell attack roll
+ defense_value=get_defense(caster,fighter)
+ ifattack_value>=defense_value:
+ spell_dmg=randint(min_damage,max_damage)# Get spell damage
+ total_hits[fighter]+=1
+ total_damage[fighter]+=spell_dmg
+
+ forfighterintargets:
+ # Construct combat message
+ iftotal_hits[fighter]==0:
+ spell_msg+=" The spell misses %s!"%fighter
+ eliftotal_hits[fighter]>0:
+ attack_count_str=atkname_single+" hits"
+ iftotal_hits[fighter]>1:
+ attack_count_str="%i%s hit"%(total_hits[fighter],atkname_plural)
+ spell_msg+=" %s%s for %i damage!"%(
+ attack_count_str,
+ fighter,
+ total_damage[fighter],
+ )
+
+ caster.db.mp-=cost# Deduct MP cost
+
+ caster.location.msg_contents(spell_msg)# Message the room with spell results
+
+ forfighterintargets:
+ # Apply damage
+ apply_damage(fighter,total_damage[fighter])
+ # If fighter HP is reduced to 0 or less, call at_defeat.
+ iffighter.db.hp<=0:
+ at_defeat(fighter)
+
+ ifis_in_combat(caster):# Spend action if in combat
+ spend_action(caster,1,action_name="cast")
+
+
+
[docs]defspell_conjure(caster,spell_name,targets,cost,**kwargs):
+ """
+ Spell that creates an object.
+
+ kwargs:
+ obj_key (str): Key of the created object.
+ obj_desc (str): Desc of the created object.
+ obj_typeclass (str): Typeclass path of the object.
+
+ If you want to make more use of this particular spell funciton,
+ you may want to modify it to use the spawner (in evennia.utils.spawner)
+ instead of creating objects directly.
+ """
+
+ obj_key="a nondescript object"
+ obj_desc="A perfectly generic object."
+ obj_typeclass="evennia.objects.objects.DefaultObject"
+
+ # Retrieve some variables from kwargs, if present
+ if"obj_key"inkwargs:
+ obj_key=kwargs["obj_key"]
+ if"obj_desc"inkwargs:
+ obj_desc=kwargs["obj_desc"]
+ if"obj_typeclass"inkwargs:
+ obj_typeclass=kwargs["obj_typeclass"]
+
+ conjured_obj=create_object(
+ obj_typeclass,key=obj_key,location=caster.location
+ )# Create object
+ conjured_obj.db.desc=obj_desc# Add object desc
+
+ caster.db.mp-=cost# Deduct MP cost
+
+ # Message the room to announce the creation of the object
+ caster.location.msg_contents(
+ "%s casts %s, and %s appears!"%(caster,spell_name,conjured_obj)
+ )
+
+
+"""
+----------------------------------------------------------------------------
+SPELL DEFINITIONS START HERE
+----------------------------------------------------------------------------
+In this section, each spell is matched to a function, and given parameters
+that determine its MP cost, valid type and number of targets, and what
+function casting the spell executes.
+
+This data is given as a dictionary of dictionaries - the key of each entry
+is the spell's name, and the value is a dictionary of various options and
+parameters, some of which are required and others which are optional.
+
+Required values for spells:
+
+ cost (int): MP cost of casting the spell
+ target (str): Valid targets for the spell. Can be any of:
+ "none" - No target needed
+ "self" - Self only
+ "any" - Any object
+ "anyobj" - Any object that isn't a character
+ "anychar" - Any character
+ "other" - Any object excluding the caster
+ "otherchar" - Any character excluding the caster
+ spellfunc (callable): Function that performs the action of the spell.
+ Must take the following arguments: caster (obj), spell_name (str),
+ targets (list), and cost (int), as well as **kwargs.
+
+Optional values for spells:
+
+ combat_spell (bool): If the spell can be cast in combat. True by default.
+ noncombat_spell (bool): If the spell can be cast out of combat. True by default.
+ max_targets (int): Maximum number of objects that can be targeted by the spell.
+ 1 by default - unused if target is "none" or "self"
+
+Any other values specified besides the above will be passed as kwargs to 'spellfunc'.
+You can use kwargs to effectively re-use the same function for different but similar
+spells - for example, 'magic missile' and 'flame shot' use the same function, but
+behave differently, as they have different damage ranges, accuracy, amount of attacks
+made as part of the spell, and so forth. If you make your spell functions flexible
+enough, you can make a wide variety of spells just by adding more entries to this
+dictionary.
+"""
+
+SPELLS={
+ "magic missile":{
+ "spellfunc":spell_attack,
+ "target":"otherchar",
+ "cost":3,
+ "noncombat_spell":False,
+ "max_targets":3,
+ "attack_name":("A bolt","bolts"),
+ "damage_range":(4,7),
+ "accuracy":999,
+ "attack_count":3,
+ },
+ "flame shot":{
+ "spellfunc":spell_attack,
+ "target":"otherchar",
+ "cost":3,
+ "noncombat_spell":False,
+ "attack_name":("A jet of flame","jets of flame"),
+ "damage_range":(25,35),
+ },
+ "cure wounds":{"spellfunc":spell_healing,"target":"anychar","cost":5},
+ "mass cure wounds":{
+ "spellfunc":spell_healing,
+ "target":"anychar",
+ "cost":10,
+ "max_targets":5,
+ },
+ "full heal":{
+ "spellfunc":spell_healing,
+ "target":"anychar",
+ "cost":12,
+ "healing_range":(100,100),
+ },
+ "cactus conjuration":{
+ "spellfunc":spell_conjure,
+ "target":"none",
+ "cost":2,
+ "combat_spell":False,
+ "obj_key":"a cactus",
+ "obj_desc":"An ordinary green cactus with little spines.",
+ },
+}
+
Source code for evennia.contrib.turnbattle.tb_range
+"""
+Simple turn-based combat system with range and movement
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' contrib that includes a system
+for abstract movement and positioning in combat, including distinction
+between melee and ranged attacks. In this system, a fighter or object's
+exact position is not recorded - only their relative distance to other
+actors in combat.
+
+In this example, the distance between two objects in combat is expressed
+as an integer value: 0 for "engaged" objects that are right next to each
+other, 1 for "reach" which is for objects that are near each other but
+not directly adjacent, and 2 for "range" for objects that are far apart.
+
+When combat starts, all fighters are at reach with each other and other
+objects, and at range from any exits. On a fighter's turn, they can use
+the "approach" command to move closer to an object, or the "withdraw"
+command to move further away from an object, either of which takes an
+action in combat. In this example, fighters are given two actions per
+turn, allowing them to move and attack in the same round, or to attack
+twice or move twice.
+
+When you move toward an object, you will also move toward anything else
+that's close to your target - the same goes for moving away from a target,
+which will also move you away from anything close to your target. Moving
+toward one target may also move you away from anything you're already
+close to, but withdrawing from a target will never inadvertently bring
+you closer to anything else.
+
+In this example, there are two attack commands. 'Attack' can only hit
+targets that are 'engaged' (range 0) with you. 'Shoot' can hit any target
+on the field, but cannot be used if you are engaged with any other fighters.
+In addition, strikes made with the 'attack' command are more accurate than
+'shoot' attacks. This is only to provide an example of how melee and ranged
+attacks can be made to work differently - you can, of course, modify this
+to fit your rules system.
+
+When in combat, the ranges of objects are also accounted for - you can't
+pick up an object unless you're engaged with it, and can't give an object
+to another fighter without being engaged with them either. Dropped objects
+are automatically assigned a range of 'engaged' with the fighter who dropped
+them. Additionally, giving or getting an object will take an action in combat.
+Dropping an object does not take an action, but can only be done on your turn.
+
+When combat ends, all range values are erased and all restrictions on getting
+or getting objects are lifted - distances are no longer tracked and objects in
+the same room can be considered to be in the same space, as is the default
+behavior of Evennia and most MUDs.
+
+This system allows for strategies in combat involving movement and
+positioning to be implemented in your battle system without the use of
+a 'grid' of coordinates, which can be difficult and clunky to navigate
+in text and disadvantageous to players who use screen readers. This loose,
+narrative method of tracking position is based around how the matter is
+handled in tabletop RPGs played without a grid - typically, a character's
+exact position in a room isn't important, only their relative distance to
+other actors.
+
+You may wish to expand this system with a method of distinguishing allies
+from enemies (to prevent allied characters from blocking your ranged attacks)
+as well as some method by which melee-focused characters can prevent enemies
+from withdrawing or punish them from doing so, such as by granting "attacks of
+opportunity" or something similar. If you wish, you can also expand the breadth
+of values allowed for range - rather than just 0, 1, and 2, you can allow ranges
+to go up to much higher values, and give attacks and movements more varying
+values for distance for a more granular system. You may also want to implement
+a system for fleeing or changing rooms in combat by approaching exits, which
+are objects placed in the range field like any other.
+
+To install and test, import this module's TBRangeCharacter object into
+your game's character.py module:
+
+ from evennia.contrib.turnbattle.tb_range import TBRangeCharacter
+
+And change your game's character typeclass to inherit from TBRangeCharacter
+instead of the default:
+
+ class Character(TBRangeCharacter):
+
+Do the same thing in your game's objects.py module for TBRangeObject:
+
+ from evennia.contrib.turnbattle.tb_range import TBRangeObject
+ class Object(TBRangeObject):
+
+Next, import this module into your default_cmdsets.py module:
+
+ from evennia.contrib.turnbattle import tb_range
+
+And add the battle command set to your default command set:
+
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(tb_range.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+fromrandomimportrandint
+fromevenniaimportDefaultCharacter,DefaultObject,Command,default_cmds,DefaultScript
+fromevennia.commands.default.helpimportCmdHelp
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT=30# Time before turns automatically end, in seconds
+ACTIONS_PER_TURN=2# Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+
[docs]defroll_init(character):
+ """
+ Rolls a number between 1-1000 to determine initiative.
+
+ Args:
+ character (obj): The character to determine initiative for
+
+ Returns:
+ initiative (int): The character's place in initiative - higher
+ numbers go first.
+
+ Notes:
+ By default, does not reference the character and simply returns
+ a random integer from 1 to 1000.
+
+ Since the character is passed to this function, you can easily reference
+ a character's stats to determine an initiative roll - for example, if your
+ character has a 'dexterity' attribute, you can use it to give that character
+ an advantage in turn order, like so:
+
+ return (randint(1,20)) + character.db.dexterity
+
+ This way, characters with a higher dexterity will go first more often.
+ """
+ returnrandint(1,1000)
+
+
+
[docs]defget_attack(attacker,defender,attack_type):
+ """
+ Returns a value for an attack roll.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+ attack_type (str): Type of attack ('melee' or 'ranged')
+
+ Returns:
+ attack_value (int): Attack roll value, compared against a defense value
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, generates a random integer from 1 to 100 without using any
+ properties from either the attacker or defender, and modifies the result
+ based on whether it's for a melee or ranged attack.
+
+ This can easily be expanded to return a value based on characters stats,
+ equipment, and abilities. This is why the attacker and defender are passed
+ to this function, even though nothing from either one are used in this example.
+ """
+ # For this example, just return a random integer up to 100.
+ attack_value=randint(1,100)
+ # Make melee attacks more accurate, ranged attacks less accurate
+ ifattack_type=="melee":
+ attack_value+=15
+ ifattack_type=="ranged":
+ attack_value-=15
+ returnattack_value
+
+
+
[docs]defget_defense(attacker,defender,attack_type):
+ """
+ Returns a value for defense, which an attack roll must equal or exceed in order
+ for an attack to hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+ attack_type (str): Type of attack ('melee' or 'ranged')
+
+ Returns:
+ defense_value (int): Defense value, compared against an attack roll
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, returns 50, not taking any properties of the defender or
+ attacker into account.
+
+ As above, this can be expanded upon based on character stats and equipment.
+ """
+ # For this example, just return 50, for about a 50/50 chance of hit.
+ defense_value=50
+ returndefense_value
+
+
+
[docs]defget_damage(attacker,defender):
+ """
+ Returns a value for damage to be deducted from the defender's HP after abilities
+ successful hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being damaged
+
+ Returns:
+ damage_value (int): Damage value, which is to be deducted from the defending
+ character's HP.
+
+ Notes:
+ By default, returns a random integer from 15 to 25 without using any
+ properties from either the attacker or defender.
+
+ Again, this can be expanded upon.
+ """
+ # For this example, just generate a number between 15 and 25.
+ damage_value=randint(15,25)
+ returndamage_value
+
+
+
[docs]defapply_damage(defender,damage):
+ """
+ Applies damage to a target, reducing their HP by the damage amount to a
+ minimum of 0.
+
+ Args:
+ defender (obj): Character taking damage
+ damage (int): Amount of damage being taken
+ """
+ defender.db.hp-=damage# Reduce defender's HP by the damage dealt.
+ # If this reduces it to 0 or less, set HP to 0.
+ ifdefender.db.hp<=0:
+ defender.db.hp=0
+
+
+
[docs]defat_defeat(defeated):
+ """
+ Announces the defeat of a fighter in combat.
+
+ Args:
+ defeated (obj): Fighter that's been defeated.
+
+ Notes:
+ All this does is announce a defeat message by default, but if you
+ want anything else to happen to defeated fighters (like putting them
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!"%defeated)
+
+
+
[docs]defresolve_attack(attacker,defender,attack_type,attack_value=None,defense_value=None):
+ """
+ Resolves an attack and outputs the result.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+ attack_type (str): Type of attack (melee or ranged)
+
+ Notes:
+ Even though the attack and defense values are calculated
+ extremely simply, they are separated out into their own functions
+ so that they are easier to expand upon.
+ """
+ # Get an attack roll from the attacker.
+ ifnotattack_value:
+ attack_value=get_attack(attacker,defender,attack_type)
+ # Get a defense value from the defender.
+ ifnotdefense_value:
+ defense_value=get_defense(attacker,defender,attack_type)
+ # If the attack value is lower than the defense value, miss. Otherwise, hit.
+ ifattack_value<defense_value:
+ attacker.location.msg_contents(
+ "%s's %s attack misses %s!"%(attacker,attack_type,defender)
+ )
+ else:
+ damage_value=get_damage(attacker,defender)# Calculate damage value.
+ # Announce damage dealt and apply damage.
+ attacker.location.msg_contents(
+ "%s hits %s with a %s attack for %i damage!"
+ %(attacker,defender,attack_type,damage_value)
+ )
+ apply_damage(defender,damage_value)
+ # If defender HP is reduced to 0 or less, call at_defeat.
+ ifdefender.db.hp<=0:
+ at_defeat(defender)
+
+
+
[docs]defget_range(obj1,obj2):
+ """
+ Gets the combat range between two objects.
+
+ Args:
+ obj1 (obj): First object
+ obj2 (obj): Second object
+
+ Returns:
+ range (int or None): Distance between two objects or None if not applicable
+ """
+ # Return None if not applicable.
+ ifnotobj1.db.combat_range:
+ returnNone
+ ifnotobj2.db.combat_range:
+ returnNone
+ ifobj1notinobj2.db.combat_range:
+ returnNone
+ ifobj2notinobj1.db.combat_range:
+ returnNone
+ # Return the range between the two objects.
+ returnobj1.db.combat_range[obj2]
+
+
+
[docs]defdistance_inc(mover,target):
+ """
+ Function that increases distance in range field between mover and target.
+
+ Args:
+ mover (obj): The object moving
+ target (obj): The object to be moved away from
+ """
+ mover.db.combat_range[target]+=1
+ target.db.combat_range[mover]=mover.db.combat_range[target]
+ # Set a cap of 2:
+ ifget_range(mover,target)>2:
+ target.db.combat_range[mover]=2
+ mover.db.combat_range[target]=2
+
+
+
[docs]defapproach(mover,target):
+ """
+ Manages a character's whole approach, including changes in ranges to other characters.
+
+ Args:
+ mover (obj): The object moving
+ target (obj): The object to be moved toward
+
+ Notes:
+ The mover will also automatically move toward any objects that are closer to the
+ target than the mover is. The mover will also move away from anything they started
+ out close to.
+ """
+
+ defdistance_dec(mover,target):
+ """
+ Helper function that decreases distance in range field between mover and target.
+
+ Args:
+ mover (obj): The object moving
+ target (obj): The object to be moved toward
+ """
+ mover.db.combat_range[target]-=1
+ target.db.combat_range[mover]=mover.db.combat_range[target]
+ # If this brings mover to range 0 (Engaged):
+ ifget_range(mover,target)<=0:
+ # Reset range to each other to 0 and copy target's ranges to mover.
+ target.db.combat_range[mover]=0
+ mover.db.combat_range=target.db.combat_range
+ # Assure everything else has the same distance from the mover and target, now that they're together
+ forthinginmover.location.contents:
+ ifthing!=moverandthing!=target:
+ thing.db.combat_range[mover]=thing.db.combat_range[target]
+
+ contents=mover.location.contents
+
+ forthingincontents:
+ ifthing!=moverandthing!=target:
+ # Move closer to each object closer to the target than you.
+ ifget_range(mover,thing)>get_range(target,thing):
+ distance_dec(mover,thing)
+ # Move further from each object that's further from you than from the target.
+ ifget_range(mover,thing)<get_range(target,thing):
+ distance_inc(mover,thing)
+ # Lastly, move closer to your target.
+ distance_dec(mover,target)
+
+
+
[docs]defwithdraw(mover,target):
+ """
+ Manages a character's whole withdrawal, including changes in ranges to other characters.
+
+ Args:
+ mover (obj): The object moving
+ target (obj): The object to be moved away from
+
+ Notes:
+ The mover will also automatically move away from objects that are close to the target
+ of their withdrawl. The mover will never inadvertently move toward anything else while
+ withdrawing - they can be considered to be moving to open space.
+ """
+
+ contents=mover.location.contents
+
+ forthingincontents:
+ ifthing!=moverandthing!=target:
+ # Move away from each object closer to the target than you, if it's also closer to you than you are to the target.
+ ifget_range(mover,thing)>=get_range(target,thing)andget_range(
+ mover,thing
+ )<get_range(mover,target):
+ distance_inc(mover,thing)
+ # Move away from anything your target is engaged with
+ ifget_range(target,thing)==0:
+ distance_inc(mover,thing)
+ # Move away from anything you're engaged with.
+ ifget_range(mover,thing)==0:
+ distance_inc(mover,thing)
+ # Then, move away from your target.
+ distance_inc(mover,target)
+
+
+
[docs]defcombat_cleanup(character):
+ """
+ Cleans up all the temporary combat-related attributes on a character.
+
+ Args:
+ character (obj): Character to have their combat attributes removed
+
+ Notes:
+ Any attribute whose key begins with 'combat_' is temporary and no
+ longer needed once a fight ends.
+ """
+ forattrincharacter.attributes.all():
+ ifattr.key[:7]=="combat_":# If the attribute name starts with 'combat_'...
+ character.attributes.remove(key=attr.key)# ...then delete it!
+
+
+
[docs]defis_in_combat(character):
+ """
+ Returns true if the given character is in combat.
+
+ Args:
+ character (obj): Character to determine if is in combat or not
+
+ Returns:
+ (bool): True if in combat or False if not in combat
+ """
+ returnbool(character.db.combat_turnhandler)
+
+
+
[docs]defis_turn(character):
+ """
+ Returns true if it's currently the given character's turn in combat.
+
+ Args:
+ character (obj): Character to determine if it is their turn or not
+
+ Returns:
+ (bool): True if it is their turn or False otherwise
+ """
+ turnhandler=character.db.combat_turnhandler
+ currentchar=turnhandler.db.fighters[turnhandler.db.turn]
+ returnbool(character==currentchar)
+
+
+
[docs]defspend_action(character,actions,action_name=None):
+ """
+ Spends a character's available combat actions and checks for end of turn.
+
+ Args:
+ character (obj): Character spending the action
+ actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
+
+ Keyword Args:
+ action_name (str or None): If a string is given, sets character's last action in
+ combat to provided string
+ """
+ ifaction_name:
+ character.db.combat_lastaction=action_name
+ ifactions=="all":# If spending all actions
+ character.db.combat_actionsleft=0# Set actions to 0
+ else:
+ character.db.combat_actionsleft-=actions# Use up actions.
+ ifcharacter.db.combat_actionsleft<0:
+ character.db.combat_actionsleft=0# Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character)# Signal potential end of turn.
+
+
+
[docs]defcombat_status_message(fighter):
+ """
+ Sends a message to a player with their current HP and
+ distances to other fighters and objects. Called at turn
+ start and by the 'status' command.
+ """
+ ifnotfighter.db.max_hp:
+ fighter.db.hp=100
+ fighter.db.max_hp=100
+
+ status_msg="HP Remaining: %i / %i"%(fighter.db.hp,fighter.db.max_hp)
+
+ ifnotis_in_combat(fighter):
+ fighter.msg(status_msg)
+ return
+
+ engaged_obj=[]
+ reach_obj=[]
+ range_obj=[]
+
+ forthinginfighter.db.combat_range:
+ ifthing!=fighter:
+ iffighter.db.combat_range[thing]==0:
+ engaged_obj.append(thing)
+ iffighter.db.combat_range[thing]==1:
+ reach_obj.append(thing)
+ iffighter.db.combat_range[thing]>1:
+ range_obj.append(thing)
+
+ ifengaged_obj:
+ status_msg+="|/Engaged targets: %s"%", ".join(obj.keyforobjinengaged_obj)
+ ifreach_obj:
+ status_msg+="|/Reach targets: %s"%", ".join(obj.keyforobjinreach_obj)
+ ifrange_obj:
+ status_msg+="|/Ranged targets: %s"%", ".join(obj.keyforobjinrange_obj)
+
+ fighter.msg(status_msg)
[docs]classTBRangeTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage'
+ command.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key="Combat Turn Handler"
+ self.interval=5# Once every 5 seconds
+ self.persistent=True
+ self.db.fighters=[]
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ forthinginself.obj.contents:
+ ifthing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ forfighterinself.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler=self
+
+ # Initialize range field for all objects in the room
+ forthinginself.obj.contents:
+ self.init_range(thing)
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll=sorted(self.db.fighters,key=roll_init,reverse=True)
+ self.db.fighters=ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s "%", ".join(obj.keyforobjinself.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn=0
+ self.db.timer=TURN_TIMEOUT# Set timer to turn timeout specified in options
+
+
[docs]defat_stop(self):
+ """
+ Called at script termination.
+ """
+ forthinginself.obj.contents:
+ combat_cleanup(thing)# Clean up the combat attributes for every object in the room.
+ self.obj.db.combat_turnhandler=None# Remove reference to turn handler in location
+
+
[docs]defat_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar=self.db.fighters[
+ self.db.turn
+ ]# Note the current character in the turn order.
+ self.db.timer-=self.interval# Count down the timer.
+
+ ifself.db.timer<=0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!"%currentchar)
+ spend_action(
+ currentchar,"all",action_name="disengage"
+ )# Spend all remaining actions.
+ return
+ elifself.db.timer<=10andnotself.db.timeout_warning_given:# 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given=True
+
+
[docs]definit_range(self,to_init):
+ """
+ Initializes range values for an object at the start of a fight.
+
+ Args:
+ to_init (object): Object to initialize range field for.
+ """
+ rangedict={}
+ # Get a list of objects in the room.
+ objectlist=self.obj.contents
+ forthinginobjectlist:
+ # Object always at distance 0 from itself
+ ifthing==to_init:
+ rangedict.update({thing:0})
+ else:
+ ifthing.destinationorto_init.destination:
+ # Start exits at range 2 to put them at the 'edges'
+ rangedict.update({thing:2})
+ else:
+ # Start objects at range 1 from other objects
+ rangedict.update({thing:1})
+ to_init.db.combat_range=rangedict
+
+
[docs]defjoin_rangefield(self,to_init,anchor_obj=None,add_distance=0):
+ """
+ Adds a new object to the range field of a fight in progress.
+
+ Args:
+ to_init (object): Object to initialize range field for.
+
+ Keyword Args:
+ anchor_obj (object): Object to copy range values from, or None for a random object.
+ add_distance (int): Distance to put between to_init object and anchor object.
+
+ """
+ # Get a list of room's contents without to_init object.
+ contents=self.obj.contents
+ contents.remove(to_init)
+ # If no anchor object given, pick one in the room at random.
+ ifnotanchor_obj:
+ anchor_obj=contents[randint(0,(len(contents)-1))]
+ # Copy the range values from the anchor object.
+ to_init.db.combat_range=anchor_obj.db.combat_range
+ # Add the new object to everyone else's ranges.
+ forthingincontents:
+ new_objects_range=thing.db.combat_range[anchor_obj]
+ thing.db.combat_range.update({to_init:new_objects_range})
+ # Set the new object's range to itself to 0.
+ to_init.db.combat_range.update({to_init:0})
+ # Add additional distance from anchor object, if any.
+ forninrange(add_distance):
+ withdraw(to_init,anchor_obj)
+
+
[docs]definitialize_for_combat(self,character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ combat_cleanup(character)# Clean up leftover combat attributes beforehand, just in case.
+ character.db.combat_actionsleft=(
+ 0# Actions remaining - start of turn adds to this, turn ends when it reaches 0
+ )
+ character.db.combat_turnhandler=(
+ self# Add a reference to this turn handler script to the character
+ )
+ character.db.combat_lastaction="null"# Track last action taken in combat
+
+
[docs]defstart_turn(self,character):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ In this example, characters are given two actions per turn. This allows
+ characters to both move and attack in the same turn (or, alternately,
+ move twice or attack twice).
+ """
+ character.db.combat_actionsleft=ACTIONS_PER_TURN# Replenish actions
+ # Prompt the character for their turn and give some information.
+ character.msg("|wIt's your turn!|n")
+ combat_status_message(character)
+
+
[docs]defnext_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check=True
+ forfighterinself.db.fighters:
+ if(
+ fighter.db.combat_lastaction!="disengage"
+ ):# If a character has done anything but disengage
+ disengage_check=False
+ ifdisengage_check:# All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters=0
+ forfighterinself.db.fighters:
+ iffighter.db.HP==0:
+ defeated_characters+=1# Add 1 for every fighter with 0 HP left (defeated)
+ ifdefeated_characters==(
+ len(self.db.fighters)-1
+ ):# If only one character isn't defeated
+ forfighterinself.db.fighters:
+ iffighter.db.HP!=0:
+ LastStanding=fighter# Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!"%LastStanding)
+ self.stop()# Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar=self.db.fighters[self.db.turn]
+ self.db.turn+=1# Go to the next in the turn order.
+ ifself.db.turn>len(self.db.fighters)-1:
+ self.db.turn=0# Go back to the first in the turn order once you reach the end.
+ newchar=self.db.fighters[self.db.turn]# Note the new character
+ self.db.timer=TURN_TIMEOUT+self.time_until_next_repeat()# Reset the timer.
+ self.db.timeout_warning_given=False# Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!"%(currentchar,newchar))
+ self.start_turn(newchar)# Start the new character's turn.
+
+
[docs]defturn_end_check(self,character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ ifnotcharacter.db.combat_actionsleft:# Character has no actions remaining
+ self.next_turn()
+ return
+
+
[docs]defjoin_fight(self,character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn,character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn+=1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
+ # Add the character to the rangefield, at range from everyone, if they're not on it already.
+ ifnotcharacter.db.combat_range:
+ self.join_rangefield(character,add_distance=2)
[docs]classTBRangeCharacter(DefaultCharacter):
+ """
+ A character able to participate in turn-based combat. Has attributes for current
+ and maximum HP, and access to combat commands.
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.max_hp=100# Set maximum HP to 100
+ self.db.hp=self.db.max_hp# Set current HP to maximum
+ """
+ Adds attributes for a character's current and maximum HP.
+ We're just going to set this value at '100' by default.
+
+ You may want to expand this to include various 'stats' that
+ can be changed at creation and factor into combat calculations.
+ """
+
+
[docs]defat_before_move(self,destination):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # Keep the character from moving if at 0 HP or in combat.
+ ifis_in_combat(self):
+ self.msg("You can't exit a room while in combat!")
+ returnFalse# Returning false keeps the character from moving.
+ ifself.db.HP<=0:
+ self.msg("You can't move, you've been defeated!")
+ returnFalse
+ returnTrue
+
+
+
[docs]classTBRangeObject(DefaultObject):
+ """
+ An object that is assigned range values in combat. Getting, giving, and dropping
+ the object has restrictions in combat - you must be next to an object to get it,
+ must be next to your target to give them something, and can only interact with
+ objects on your own turn.
+ """
+
+
[docs]defat_before_drop(self,dropper):
+ """
+ Called by the default `drop` command before this object has been
+ dropped.
+
+ Args:
+ dropper (Object): The object which will drop this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shoulddrop (bool): If the object should be dropped or not.
+
+ Notes:
+ If this method returns False/None, the dropping is cancelled
+ before it is even started.
+
+ """
+ # Can't drop something if in combat and it's not your turn
+ ifis_in_combat(dropper)andnotis_turn(dropper):
+ dropper.msg("You can only drop things on your turn!")
+ returnFalse
+ returnTrue
+
+
[docs]defat_drop(self,dropper):
+ """
+ Called by the default `drop` command when this object has been
+ dropped.
+
+ Args:
+ dropper (Object): The object which just dropped this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This hook cannot stop the drop from happening. Use
+ permissions or the at_before_drop() hook for that.
+
+ """
+ # If dropper is currently in combat
+ ifdropper.location.db.combat_turnhandler:
+ # Object joins the range field
+ self.db.combat_range={}
+ dropper.location.db.combat_turnhandler.join_rangefield(self,anchor_obj=dropper)
+
+
[docs]defat_before_get(self,getter):
+ """
+ Called by the default `get` command before this object has been
+ picked up.
+
+ Args:
+ getter (Object): The object about to get this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shouldget (bool): If the object should be gotten or not.
+
+ Notes:
+ If this method returns False/None, the getting is cancelled
+ before it is even started.
+ """
+ # Restrictions for getting in combat
+ ifis_in_combat(getter):
+ ifnotis_turn(getter):# Not your turn
+ getter.msg("You can only get things on your turn!")
+ returnFalse
+ ifget_range(self,getter)>0:# Too far away
+ getter.msg("You aren't close enough to get that! (see: help approach)")
+ returnFalse
+ returnTrue
+
+
[docs]defat_get(self,getter):
+ """
+ Called by the default `get` command when this object has been
+ picked up.
+
+ Args:
+ getter (Object): The object getting this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This hook cannot stop the pickup from happening. Use
+ permissions or the at_before_get() hook for that.
+
+ """
+ # If gotten, erase range values
+ ifself.db.combat_range:
+ delself.db.combat_range
+ # Remove this object from everyone's range fields
+ forthingingetter.location.contents:
+ ifthing.db.combat_range:
+ ifselfinthing.db.combat_range:
+ thing.db.combat_range.pop(self,None)
+ # If in combat, getter spends an action
+ ifis_in_combat(getter):
+ spend_action(getter,1,action_name="get")# Use up one action.
+
+
[docs]defat_before_give(self,giver,getter):
+ """
+ Called by the default `give` command before this object has been
+ given.
+
+ Args:
+ giver (Object): The object about to give this object.
+ getter (Object): The object about to get this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shouldgive (bool): If the object should be given or not.
+
+ Notes:
+ If this method returns False/None, the giving is cancelled
+ before it is even started.
+
+ """
+ # Restrictions for giving in combat
+ ifis_in_combat(giver):
+ ifnotis_turn(giver):# Not your turn
+ giver.msg("You can only give things on your turn!")
+ returnFalse
+ ifget_range(giver,getter)>0:# Too far away from target
+ giver.msg(
+ "You aren't close enough to give things to %s! (see: help approach)"%getter
+ )
+ returnFalse
+ returnTrue
+
+
[docs]defat_give(self,giver,getter):
+ """
+ Called by the default `give` command when this object has been
+ given.
+
+ Args:
+ giver (Object): The object giving this object.
+ getter (Object): The object getting this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This hook cannot stop the give from happening. Use
+ permissions or the at_before_give() hook for that.
+
+ """
+ # Spend an action if in combat
+ ifis_in_combat(giver):
+ spend_action(giver,1,action_name="give")# Use up one action.
[docs]classCmdFight(Command):
+ """
+ Starts a fight with everyone in the same room as you.
+
+ Usage:
+ fight
+
+ When you start a fight, everyone in the room who is able to
+ fight is added to combat, and a turn order is randomly rolled.
+ When it's your turn, you can attack other characters.
+ """
+
+ key="fight"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ here=self.caller.location
+ fighters=[]
+
+ ifnotself.caller.db.hp:# If you don't have any hp
+ self.caller.msg("You can't start a fight if you've been defeated!")
+ return
+ ifis_in_combat(self.caller):# Already in a fight
+ self.caller.msg("You're already in a fight!")
+ return
+ forthinginhere.contents:# Test everything in the room to add it to the fight.
+ ifthing.db.HP:# If the object has HP...
+ fighters.append(thing)# ...then add it to the fight.
+ iflen(fighters)<=1:# If you're the only able fighter in the room
+ self.caller.msg("There's nobody here to fight!")
+ return
+ ifhere.db.combat_turnhandler:# If there's already a fight going on...
+ here.msg_contents("%s joins the fight!"%self.caller)
+ here.db.combat_turnhandler.join_fight(self.caller)# Join the fight!
+ return
+ here.msg_contents("%s starts a fight!"%self.caller)
+ # Add a turn handler script to the room, which starts combat.
+ here.scripts.add("contrib.turnbattle.tb_range.TBRangeTurnHandler")
+ # Remember you'll have to change the path to the script if you copy this code to your own modules!
+
+
+
[docs]classCmdAttack(Command):
+ """
+ Attacks another character in melee.
+
+ Usage:
+ attack <target>
+
+ When in a fight, you may attack another character. The attack has
+ a chance to hit, and if successful, will deal damage. You can only
+ attack engaged targets - that is, targets that are right next to
+ you. Use the 'approach' command to get closer to a target.
+ """
+
+ key="attack"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ ifnotis_in_combat(self.caller):# If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ ifnotself.caller.db.hp:# Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ attacker=self.caller
+ defender=self.caller.search(self.args)
+
+ ifnotdefender:# No valid target given.
+ return
+
+ ifnotdefender.db.hp:# Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ ifattacker==defender:# Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ ifnotget_range(attacker,defender)==0:# Target isn't in melee
+ self.caller.msg(
+ "%s is too far away to attack - you need to get closer! (see: help approach)"
+ %defender
+ )
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker,defender,"melee")
+ spend_action(self.caller,1,action_name="attack")# Use up one action.
+
+
+
[docs]classCmdShoot(Command):
+ """
+ Attacks another character from range.
+
+ Usage:
+ shoot <target>
+
+ When in a fight, you may shoot another character. The attack has
+ a chance to hit, and if successful, will deal damage. You can attack
+ any target in combat by shooting, but can't shoot if there are any
+ targets engaged with you. Use the 'withdraw' command to retreat from
+ nearby enemies.
+ """
+
+ key="shoot"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ ifnotis_in_combat(self.caller):# If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ ifnotself.caller.db.hp:# Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ attacker=self.caller
+ defender=self.caller.search(self.args)
+
+ ifnotdefender:# No valid target given.
+ return
+
+ ifnotdefender.db.hp:# Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ ifattacker==defender:# Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ # Test to see if there are any nearby enemy targets.
+ in_melee=[]
+ fortargetinattacker.db.combat_range:
+ # Object is engaged and has HP
+ ifget_range(attacker,defender)==0andtarget.db.hpandtarget!=self.caller:
+ in_melee.append(target)# Add to list of targets in melee
+
+ iflen(in_melee)>0:
+ self.caller.msg(
+ "You can't shoot because there are fighters engaged with you (%s) - you need to retreat! (see: help withdraw)"
+ %", ".join(obj.keyforobjinin_melee)
+ )
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker,defender,"ranged")
+ spend_action(self.caller,1,action_name="attack")# Use up one action.
+
+
+
[docs]classCmdApproach(Command):
+ """
+ Approaches an object.
+
+ Usage:
+ approach <target>
+
+ Move one space toward a character or object. You can only attack
+ characters you are 0 spaces away from.
+ """
+
+ key="approach"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+
+ ifnotis_in_combat(self.caller):# If not in combat, can't approach.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn, can't approach.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ ifnotself.caller.db.hp:# Can't approach if you have no HP.
+ self.caller.msg("You can't move, you've been defeated.")
+ return
+
+ mover=self.caller
+ target=self.caller.search(self.args)
+
+ ifnottarget:# No valid target given.
+ return
+
+ ifnottarget.db.combat_range:# Target object is not on the range field
+ self.caller.msg("You can't move toward that!")
+ return
+
+ ifmover==target:# Target and mover are the same
+ self.caller.msg("You can't move toward yourself!")
+ return
+
+ ifget_range(mover,target)<=0:# Already engaged with target
+ self.caller.msg("You're already next to that target!")
+ return
+
+ # If everything checks out, call the approach resolving function.
+ approach(mover,target)
+ mover.location.msg_contents("%s moves toward %s."%(mover,target))
+ spend_action(self.caller,1,action_name="move")# Use up one action.
+
+
+
[docs]classCmdWithdraw(Command):
+ """
+ Moves away from an object.
+
+ Usage:
+ withdraw <target>
+
+ Move one space away from a character or object.
+ """
+
+ key="withdraw"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+
+ ifnotis_in_combat(self.caller):# If not in combat, can't withdraw.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn, can't withdraw.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ ifnotself.caller.db.hp:# Can't withdraw if you have no HP.
+ self.caller.msg("You can't move, you've been defeated.")
+ return
+
+ mover=self.caller
+ target=self.caller.search(self.args)
+
+ ifnottarget:# No valid target given.
+ return
+
+ ifnottarget.db.combat_range:# Target object is not on the range field
+ self.caller.msg("You can't move away from that!")
+ return
+
+ ifmover==target:# Target and mover are the same
+ self.caller.msg("You can't move away from yourself!")
+ return
+
+ ifmover.db.combat_range[target]>=3:# Already at maximum distance
+ self.caller.msg("You're as far as you can get from that target!")
+ return
+
+ # If everything checks out, call the approach resolving function.
+ withdraw(mover,target)
+ mover.location.msg_contents("%s moves away from %s."%(mover,target))
+ spend_action(self.caller,1,action_name="move")# Use up one action.
+
+
+
[docs]classCmdPass(Command):
+ """
+ Passes on your turn.
+
+ Usage:
+ pass
+
+ When in a fight, you can use this command to end your turn early, even
+ if there are still any actions you can take.
+ """
+
+ key="pass"
+ aliases=["wait","hold"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# Can only pass a turn in combat.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# Can only pass if it's your turn.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents(
+ "%s takes no further action, passing the turn."%self.caller
+ )
+ spend_action(self.caller,"all",action_name="pass")# Spend all remaining actions.
+
+
+
[docs]classCmdDisengage(Command):
+ """
+ Passes your turn and attempts to end combat.
+
+ Usage:
+ disengage
+
+ Ends your turn early and signals that you're trying to end
+ the fight. If all participants in a fight disengage, the
+ fight ends.
+ """
+
+ key="disengage"
+ aliases=["spare"]
+ help_category="combat"
+
+
[docs]deffunc(self):
+ """
+ This performs the actual command.
+ """
+ ifnotis_in_combat(self.caller):# If you're not in combat
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ ifnotis_turn(self.caller):# If it's not your turn
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s disengages, ready to stop fighting."%self.caller)
+ spend_action(self.caller,"all",action_name="disengage")# Spend all remaining actions.
+ """
+ The action_name kwarg sets the character's last action to "disengage", which is checked by
+ the turn handler script to see if all fighters have disengaged.
+ """
+
+
+
[docs]classCmdRest(Command):
+ """
+ Recovers damage.
+
+ Usage:
+ rest
+
+ Resting recovers your HP to its maximum, but you can only
+ rest if you're not in a fight.
+ """
+
+ key="rest"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+
+ ifis_in_combat(self.caller):# If you're in combat
+ self.caller.msg("You can't rest while you're in combat.")
+ return
+
+ self.caller.db.hp=self.caller.db.max_hp# Set current HP to maximum
+ self.caller.location.msg_contents("%s rests to recover HP."%self.caller)
+ """
+ You'll probably want to replace this with your own system for recovering HP.
+ """
+
+
+
[docs]classCmdStatus(Command):
+ """
+ Gives combat information.
+
+ Usage:
+ status
+
+ Shows your current and maximum HP and your distance from
+ other targets in combat.
+ """
+
+ key="status"
+ help_category="combat"
+
+
[docs]deffunc(self):
+ "This performs the actual command."
+ combat_status_message(self.caller)
+
+
+
[docs]classCmdCombatHelp(CmdHelp):
+ """
+ View help or a list of topics
+
+ Usage:
+ help <topic or command>
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+
+ # Just like the default help command, but will give quick
+ # tips on combat when used in a fight with no arguments.
+
+
[docs]deffunc(self):
+ ifis_in_combat(self.caller)andnotself.args:# In combat and entered 'help' alone
+ self.caller.msg(
+ "Available combat commands:|/"
+ +"|wAttack:|n Attack an engaged target, attempting to deal damage.|/"
+ +"|wShoot:|n Attack from a distance, if not engaged with other fighters.|/"
+ +"|wApproach:|n Move one step cloer to a target.|/"
+ +"|wWithdraw:|n Move one step away from a target.|/"
+ +"|wPass:|n Pass your turn without further action.|/"
+ +"|wStatus:|n View current HP and ranges to other targets.|/"
+ +"|wDisengage:|n End your turn and attempt to end combat.|/"
+ )
+ else:
+ super(CmdCombatHelp,self).func()# Call the default help command
+
+
+
[docs]classBattleCmdSet(default_cmds.CharacterCmdSet):
+ """
+ This command set includes all the commmands used in the battle system.
+ """
+
+ key="DefaultCharacter"
+
+
Source code for evennia.contrib.tutorial_examples.bodyfunctions
+"""
+Example script for testing. This adds a simple timer that has your
+character make observations and notices at irregular intervals.
+
+To test, use
+ @script me = tutorial_examples.bodyfunctions.BodyFunctions
+
+The script will only send messages to the object it is stored on, so
+make sure to put it on yourself or you won't see any messages!
+
+"""
+importrandom
+fromevenniaimportDefaultScript
+
+
+
[docs]classBodyFunctions(DefaultScript):
+ """
+ This class defines the script itself
+ """
+
+
[docs]defat_script_creation(self):
+ self.key="bodyfunction"
+ self.desc="Adds various timed events to a character."
+ self.interval=20# seconds
+ # self.repeats = 5 # repeat only a certain number of times
+ self.start_delay=True# wait self.interval until first call
+ # self.persistent = True
+
+
[docs]defat_repeat(self):
+ """
+ This gets called every self.interval seconds. We make
+ a random check here so as to only return 33% of the time.
+ """
+ ifrandom.random()<0.66:
+ # no message this time
+ return
+ self.send_random_message()
+
+
[docs]defsend_random_message(self):
+ rand=random.random()
+ # return a random message
+ ifrand<0.1:
+ string="You tap your foot, looking around."
+ elifrand<0.2:
+ string="You have an itch. Hard to reach too."
+ elifrand<0.3:
+ string=(
+ "You think you hear someone behind you. ... but when you look there's noone there."
+ )
+ elifrand<0.4:
+ string="You inspect your fingernails. Nothing to report."
+ elifrand<0.5:
+ string="You cough discreetly into your hand."
+ elifrand<0.6:
+ string="You scratch your head, looking around."
+ elifrand<0.7:
+ string="You blink, forgetting what it was you were going to do."
+ elifrand<0.8:
+ string="You feel lonely all of a sudden."
+ elifrand<0.9:
+ string="You get a great idea. Of course you won't tell anyone."
+ else:
+ string="You suddenly realize how much you love Evennia!"
+
+ # echo the message to the object
+ self.obj.msg(string)
Source code for evennia.contrib.tutorial_examples.cmdset_red_button
+"""
+This defines the cmdset for the red_button. Here we have defined
+the commands and the cmdset in the same module, but if you
+have many different commands to merge it is often better
+to define the cmdset separately, picking and choosing from
+among the available commands as to what should be included in the
+cmdset - this way you can often re-use the commands too.
+"""
+
+importrandom
+fromevenniaimportCommand,CmdSet
+
+# Some simple commands for the red button
+
+# ------------------------------------------------------------
+# Commands defined on the red button
+# ------------------------------------------------------------
+
+
+
[docs]classCmdNudge(Command):
+ """
+ Try to nudge the button's lid
+
+ Usage:
+ nudge lid
+
+ This command will have you try to
+ push the lid of the button away.
+ """
+
+ key="nudge lid"# two-word command name!
+ aliases=["nudge"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """
+ nudge the lid. Random chance of success to open it.
+ """
+ rand=random.random()
+ ifrand<0.5:
+ self.caller.msg("You nudge at the lid. It seems stuck.")
+ elifrand<0.7:
+ self.caller.msg("You move the lid back and forth. It won't budge.")
+ else:
+ self.caller.msg("You manage to get a nail under the lid.")
+ self.caller.execute_cmd("open lid")
[docs]deffunc(self):
+ """
+ Note that we choose to implement this with checking for
+ if the lid is open/closed. This is because this command
+ is likely to be tried regardless of the state of the lid.
+
+ An alternative would be to make two versions of this command
+ and tuck them into the cmdset linked to the Open and Closed
+ lid-state respectively.
+
+ """
+
+ ifself.obj.db.lid_open:
+ string="You reach out to press the big red button ..."
+ string+="\n\nA BOOM! A bright light blinds you!"
+ string+="\nThe world goes dark ..."
+ self.caller.msg(string)
+ self.caller.location.msg_contents(
+ "%s presses the button. BOOM! %s is blinded by a flash!"
+ %(self.caller.name,self.caller.name),
+ exclude=self.caller,
+ )
+ # the button's method will handle all setup of scripts etc.
+ self.obj.press_button(self.caller)
+ else:
+ string="You cannot push the button - there is a glass lid covering it."
+ self.caller.msg(string)
+
+
+
[docs]classCmdSmashGlass(Command):
+ """
+ smash glass
+
+ Usage:
+ smash glass
+
+ Try to smash the glass of the button.
+ """
+
+ key="smash glass"
+ aliases=["smash lid","break lid","smash"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """
+ The lid won't open, but there is a small chance
+ of causing the lamp to break.
+ """
+ rand=random.random()
+
+ ifrand<0.2:
+ string="You smash your hand against the glass"
+ string+=" with all your might. The lid won't budge"
+ string+=" but you cause quite the tremor through the button's mount."
+ string+="\nIt looks like the button's lamp stopped working for the time being."
+ self.obj.lamp_works=False
+ elifrand<0.6:
+ string="You hit the lid hard. It doesn't move an inch."
+ else:
+ string="You place a well-aimed fist against the glass of the lid."
+ string+=" Unfortunately all you get is a pain in your hand. Maybe"
+ string+=" you should just try to open the lid instead?"
+ self.caller.msg(string)
+ self.caller.location.msg_contents(
+ "%s tries to smash the glass of the button."%(self.caller.name),exclude=self.caller
+ )
+
+
+
[docs]classCmdOpenLid(Command):
+ """
+ open lid
+
+ Usage:
+ open lid
+
+ """
+
+ key="open lid"
+ aliases=["open button","open"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ "simply call the right function."
+
+ ifself.obj.db.lid_locked:
+ self.caller.msg("This lid seems locked in place for the moment.")
+ return
+
+ string="\nA ticking sound is heard, like a winding mechanism. Seems "
+ string+="the lid will soon close again."
+ self.caller.msg(string)
+ self.caller.location.msg_contents(
+ "%s opens the lid of the button."%(self.caller.name),exclude=self.caller
+ )
+ # add the relevant cmdsets to button
+ self.obj.cmdset.add(LidClosedCmdSet)
+ # call object method
+ self.obj.open_lid()
+
+
+
[docs]classCmdCloseLid(Command):
+ """
+ close the lid
+
+ Usage:
+ close lid
+
+ Closes the lid of the red button.
+ """
+
+ key="close lid"
+ aliases=["close"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ "Close the lid"
+
+ self.obj.close_lid()
+
+ # this will clean out scripts dependent on lid being open.
+ self.caller.msg("You close the button's lid. It clicks back into place.")
+ self.caller.location.msg_contents(
+ "%s closes the button's lid."%(self.caller.name),exclude=self.caller
+ )
+
+
+
[docs]classCmdBlindLook(Command):
+ """
+ Looking around in darkness
+
+ Usage:
+ look <obj>
+
+ ... not that there's much to see in the dark.
+
+ """
+
+ key="look"
+ aliases=["l","get","examine","ex","feel","listen"]
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ "This replaces all the senses when blinded."
+
+ # we decide what to reply based on which command was
+ # actually tried
+
+ ifself.cmdstring=="get":
+ string="You fumble around blindly without finding anything."
+ elifself.cmdstring=="examine":
+ string="You try to examine your surroundings, but can't see a thing."
+ elifself.cmdstring=="listen":
+ string="You are deafened by the boom."
+ elifself.cmdstring=="feel":
+ string="You fumble around, hands outstretched. You bump your knee."
+ else:
+ # trying to look
+ string="You are temporarily blinded by the flash. "
+ string+="Until it wears off, all you can do is feel around blindly."
+ self.caller.msg(string)
+ self.caller.location.msg_contents(
+ "%s stumbles around, blinded."%(self.caller.name),exclude=self.caller
+ )
+
+
+
[docs]classCmdBlindHelp(Command):
+ """
+ Help function while in the blinded state
+
+ Usage:
+ help
+
+ """
+
+ key="help"
+ aliases="h"
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ "Give a message."
+ self.caller.msg("You are beyond help ... until you can see again.")
+
+
+# ---------------------------------------------------------------
+# Command sets for the red button
+# ---------------------------------------------------------------
+
+# We next tuck these commands into their respective command sets.
+# (note that we are overdoing the cdmset separation a bit here
+# to show how it works).
+
+
+
[docs]classDefaultCmdSet(CmdSet):
+ """
+ The default cmdset always sits
+ on the button object and whereas other
+ command sets may be added/merge onto it
+ and hide it, removing them will always
+ bring it back. It's added to the object
+ using obj.cmdset.add_default().
+ """
+
+ key="RedButtonDefault"
+ mergetype="Union"# this is default, we don't really need to put it here.
+
+
[docs]defat_cmdset_creation(self):
+ "Init the cmdset"
+ self.add(CmdPush())
+
+
+
[docs]classLidClosedCmdSet(CmdSet):
+ """
+ A simple cmdset tied to the redbutton object.
+
+ It contains the commands that launches the other
+ command sets, making the red button a self-contained
+ item (i.e. you don't have to manually add any
+ scripts etc to it when creating it).
+ """
+
+ key="LidClosedCmdSet"
+ # default Union is used *except* if we are adding to a
+ # cmdset named LidOpenCmdSet - this one we replace
+ # completely.
+ key_mergetype={"LidOpenCmdSet":"Replace"}
+
+
[docs]defat_cmdset_creation(self):
+ "Populates the cmdset when it is instantiated."
+ self.add(CmdNudge())
+ self.add(CmdSmashGlass())
+ self.add(CmdOpenLid())
+
+
+
[docs]classLidOpenCmdSet(CmdSet):
+ """
+ This is the opposite of the Closed cmdset.
+ """
+
+ key="LidOpenCmdSet"
+ # default Union is used *except* if we are adding to a
+ # cmdset named LidClosedCmdSet - this one we replace
+ # completely.
+ key_mergetype={"LidClosedCmdSet":"Replace"}
+
+
[docs]defat_cmdset_creation(self):
+ "setup the cmdset (just one command)"
+ self.add(CmdCloseLid())
+
+
+
[docs]classBlindCmdSet(CmdSet):
+ """
+ This is the cmdset added to the *account* when
+ the button is pushed.
+ """
+
+ key="BlindCmdSet"
+ # we want it to completely replace all normal commands
+ # until the timed script removes it again.
+ mergetype="Replace"
+ # we want to stop the account from walking around
+ # in this blinded state, so we hide all exits too.
+ # (channel commands will still work).
+ no_exits=True# keep account in the same room
+ no_objs=True# don't allow object commands
+
+
Source code for evennia.contrib.tutorial_examples.red_button
+"""
+
+This is a more advanced example object. It combines functions from
+script.examples as well as commands.examples to make an interactive
+button typeclass.
+
+Create this button with
+
+ @create/drop examples.red_button.RedButton
+
+Note that you must drop the button before you can see its messages!
+"""
+importrandom
+fromevenniaimportDefaultObject
+fromevennia.contrib.tutorial_examplesimportred_button_scriptsasscriptexamples
+fromevennia.contrib.tutorial_examplesimportcmdset_red_buttonascmdsetexamples
+
+#
+# Definition of the object itself
+#
+
+
+
[docs]classRedButton(DefaultObject):
+ """
+ This class describes an evil red button. It will use the script
+ definition in contrib/examples/red_button_scripts to blink at regular
+ intervals. It also uses a series of script and commands to handle
+ pushing the button and causing effects when doing so.
+
+ The following attributes can be set on the button:
+ desc_lid_open - description when lid is open
+ desc_lid_closed - description when lid is closed
+ desc_lamp_broken - description when lamp is broken
+
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ This function is called when object is created. Use this
+ instead of e.g. __init__.
+ """
+ # store desc (default, you can change this at creation time)
+ desc="This is a large red button, inviting yet evil-looking. "
+ desc+="A closed glass lid protects it."
+ self.db.desc=desc
+
+ # We have to define all the variables the scripts
+ # are checking/using *before* adding the scripts or
+ # they might be deactivated before even starting!
+ self.db.lid_open=False
+ self.db.lamp_works=True
+ self.db.lid_locked=False
+
+ self.cmdset.add_default(cmdsetexamples.DefaultCmdSet,permanent=True)
+
+ # since the cmdsets relevant to the button are added 'on the fly',
+ # we need to setup custom scripts to do this for us (also, these scripts
+ # check so they are valid (i.e. the lid is actually still closed)).
+ # The AddClosedCmdSet script makes sure to add the Closed-cmdset.
+ self.scripts.add(scriptexamples.ClosedLidState)
+ # the script EventBlinkButton makes the button blink regularly.
+ self.scripts.add(scriptexamples.BlinkButtonEvent)
+
+ # state-changing methods
+
+
[docs]defopen_lid(self):
+ """
+ Opens the glass lid and start the timer so it will soon close
+ again.
+
+ """
+
+ ifself.db.lid_open:
+ return
+ desc=self.db.desc_lid_open
+ ifnotdesc:
+ desc="This is a large red button, inviting yet evil-looking. "
+ desc+="Its glass cover is open and the button exposed."
+ self.db.desc=desc
+ self.db.lid_open=True
+
+ # with the lid open, we validate scripts; this will clean out
+ # scripts that depend on the lid to be closed.
+ self.scripts.validate()
+ # now add new scripts that define the open-lid state
+ self.scripts.add(scriptexamples.OpenLidState)
+ # we also add a scripted event that will close the lid after a while.
+ # (this one cleans itself after being called once)
+ self.scripts.add(scriptexamples.CloseLidEvent)
+
+
[docs]defclose_lid(self):
+ """
+ Close the glass lid. This validates all scripts on the button,
+ which means that scripts only being valid when the lid is open
+ will go away automatically.
+
+ """
+
+ ifnotself.db.lid_open:
+ return
+ desc=self.db.desc_lid_closed
+ ifnotdesc:
+ desc="This is a large red button, inviting yet evil-looking. "
+ desc+="Its glass cover is closed, protecting it."
+ self.db.desc=desc
+ self.db.lid_open=False
+
+ # clean out scripts depending on lid to be open
+ self.scripts.validate()
+ # add scripts related to the closed state
+ self.scripts.add(scriptexamples.ClosedLidState)
+
+
[docs]defbreak_lamp(self,feedback=True):
+ """
+ Breaks the lamp in the button, stopping it from blinking.
+
+ Args:
+ feedback (bool): Show a message about breaking the lamp.
+
+ """
+ self.db.lamp_works=False
+ desc=self.db.desc_lamp_broken
+ ifnotdesc:
+ self.db.desc+="\nThe big red button has stopped blinking for the time being."
+ else:
+ self.db.desc=desc
+
+ iffeedbackandself.location:
+ self.location.msg_contents("The lamp flickers, the button going dark.")
+ self.scripts.validate()
+
+
[docs]defpress_button(self,pobject):
+ """
+ Someone was foolish enough to press the button!
+
+ Args:
+ pobject (Object): The person pressing the button
+
+ """
+ # deactivate the button so it won't flash/close lid etc.
+ self.scripts.add(scriptexamples.DeactivateButtonEvent)
+ # blind the person pressing the button. Note that this
+ # script is set on the *character* pressing the button!
+ pobject.scripts.add(scriptexamples.BlindedState)
+
+ # script-related methods
+
+
[docs]defblink(self):
+ """
+ The script system will regularly call this
+ function to make the button blink. Now and then
+ it won't blink at all though, to add some randomness
+ to how often the message is echoed.
+ """
+ loc=self.location
+ ifloc:
+ rand=random.random()
+ ifrand<0.2:
+ string="The red button flashes briefly."
+ elifrand<0.4:
+ string="The red button blinks invitingly."
+ elifrand<0.6:
+ string="The red button flashes. You know you wanna push it!"
+ else:
+ # no blink
+ return
+ loc.msg_contents(string)
Source code for evennia.contrib.tutorial_examples.red_button_scripts
+"""
+Example of scripts.
+
+These are scripts intended for a particular object - the
+red_button object type in contrib/examples. A few variations
+on uses of scripts are included.
+
+"""
+fromevenniaimportDefaultScript
+fromevennia.contrib.tutorial_examplesimportcmdset_red_buttonascmdsetexamples
+
+#
+# Scripts as state-managers
+#
+# Scripts have many uses, one of which is to statically
+# make changes when a particular state of an object changes.
+# There is no "timer" involved in this case (although there could be),
+# whenever the script determines it is "invalid", it simply shuts down
+# along with all the things it controls.
+#
+# To show as many features as possible of the script and cmdset systems,
+# we will use three scripts controlling one state each of the red_button,
+# each with its own set of commands, handled by cmdsets - one for when
+# the button has its lid open, and one for when it is closed and a
+# last one for when the player pushed the button and gets blinded by
+# a bright light. The last one also has a timer component that allows it
+# to remove itself after a while (and the player recovers their eyesight).
+
+
+
[docs]classClosedLidState(DefaultScript):
+ """
+ This manages the cmdset for the "closed" button state. What this
+ means is that while this script is valid, we add the RedButtonClosed
+ cmdset to it (with commands like open, nudge lid etc)
+ """
+
+
[docs]defat_script_creation(self):
+ "Called when script first created."
+ self.key="closed_lid_script"
+ self.desc="Script that manages the closed-state cmdsets for red button."
+ self.persistent=True
+
+
[docs]defat_start(self):
+ """
+ This is called once every server restart, so we want to add the
+ (memory-resident) cmdset to the object here. is_valid is automatically
+ checked so we don't need to worry about adding the script to an
+ open lid.
+ """
+ # All we do is add the cmdset for the closed state.
+ self.obj.cmdset.add(cmdsetexamples.LidClosedCmdSet)
+
+
[docs]defis_valid(self):
+ """
+ The script is only valid while the lid is closed.
+ self.obj is the red_button on which this script is defined.
+ """
+ returnnotself.obj.db.lid_open
+
+
[docs]defat_stop(self):
+ """
+ When the script stops we must make sure to clean up after us.
+
+ """
+ self.obj.cmdset.delete(cmdsetexamples.LidClosedCmdSet)
+
+
+
[docs]classOpenLidState(DefaultScript):
+ """
+ This manages the cmdset for the "open" button state. This will add
+ the RedButtonOpen
+ """
+
+
[docs]defat_script_creation(self):
+ "Called when script first created."
+ self.key="open_lid_script"
+ self.desc="Script that manages the opened-state cmdsets for red button."
+ self.persistent=True
+
+
[docs]defat_start(self):
+ """
+ This is called once every server restart, so we want to add the
+ (memory-resident) cmdset to the object here. is_valid is
+ automatically checked, so we don't need to worry about
+ adding the cmdset to a closed lid-button.
+ """
+ self.obj.cmdset.add(cmdsetexamples.LidOpenCmdSet)
+
+
[docs]defis_valid(self):
+ """
+ The script is only valid while the lid is open.
+ self.obj is the red_button on which this script is defined.
+ """
+ returnself.obj.db.lid_open
+
+
[docs]defat_stop(self):
+ """
+ When the script stops (like if the lid is closed again)
+ we must make sure to clean up after us.
+ """
+ self.obj.cmdset.delete(cmdsetexamples.LidOpenCmdSet)
+
+
+
[docs]classBlindedState(DefaultScript):
+ """
+ This is a timed state.
+
+ This adds a (very limited) cmdset TO THE ACCOUNT, during a certain time,
+ after which the script will close and all functions are
+ restored. It's up to the function starting the script to actually
+ set it on the right account object.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ We set up the script here.
+ """
+ self.key="temporary_blinder"
+ self.desc="Temporarily blinds the account for a little while."
+ self.interval=20# seconds
+ self.start_delay=True# we don't want it to stop until after 20s.
+ self.repeats=1# this will go away after interval seconds.
+ self.persistent=False# we will ditch this if server goes down
+
+
[docs]defat_start(self):
+ """
+ We want to add the cmdset to the linked object.
+
+ Note that the RedButtonBlind cmdset is defined to completly
+ replace the other cmdsets on the stack while it is active
+ (this means that while blinded, only operations in this cmdset
+ will be possible for the account to perform). It is however
+ not persistent, so should there be a bug in it, we just need
+ to restart the server to clear out of it during development.
+ """
+ self.obj.cmdset.add(cmdsetexamples.BlindCmdSet)
+
+
[docs]defat_stop(self):
+ """
+ It's important that we clear out that blinded cmdset
+ when we are done!
+ """
+ self.obj.msg("You blink feverishly as your eyesight slowly returns.")
+ self.obj.location.msg_contents(
+ "%s seems to be recovering their eyesight."%self.obj.name,exclude=self.obj
+ )
+ self.obj.cmdset.delete()# this will clear the latest added cmdset,
+ # (which is the blinded one).
+
+
+#
+# Timer/Event-like Scripts
+#
+# Scripts can also work like timers, or "events". Below we
+# define three such timed events that makes the button a little
+# more "alive" - one that makes the button blink menacingly, another
+# that makes the lid covering the button slide back after a while.
+#
+
+
+
[docs]classCloseLidEvent(DefaultScript):
+ """
+ This event closes the glass lid over the button
+ some time after it was opened. It's a one-off
+ script that should be started/created when the
+ lid is opened.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Called when script object is first created. Sets things up.
+ We want to have a lid on the button that the user can pull
+ aside in order to make the button 'pressable'. But after a set
+ time that lid should auto-close again, making the button safe
+ from pressing (and deleting this command).
+ """
+ self.key="lid_closer"
+ self.desc="Closes lid on a red buttons"
+ self.interval=20# seconds
+ self.start_delay=True# we want to pospone the launch.
+ self.repeats=1# we only close the lid once
+ self.persistent=True# even if the server crashes in those 20 seconds,
+ # the lid will still close once the game restarts.
+
+
[docs]defis_valid(self):
+ """
+ This script can only operate if the lid is open; if it
+ is already closed, the script is clearly invalid.
+
+ Note that we are here relying on an self.obj being
+ defined (and being a RedButton object) - this we should be able to
+ expect since this type of script is always tied to one individual
+ red button object and not having it would be an error.
+ """
+ returnself.obj.db.lid_open
+
+
[docs]defat_repeat(self):
+ """
+ Called after self.interval seconds. It closes the lid. Before this method is
+ called, self.is_valid() is automatically checked, so there is no need to
+ check this manually.
+ """
+ self.obj.close_lid()
+
+
+
[docs]classBlinkButtonEvent(DefaultScript):
+ """
+ This timed script lets the button flash at regular intervals.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Sets things up. We want the button's lamp to blink at
+ regular intervals, unless it's broken (can happen
+ if you try to smash the glass, say).
+ """
+ self.key="blink_button"
+ self.desc="Blinks red buttons"
+ self.interval=35# seconds
+ self.start_delay=False# blink right away
+ self.persistent=True# keep blinking also after server reboot
+
+
[docs]defis_valid(self):
+ """
+ Button will keep blinking unless it is broken.
+ """
+ returnself.obj.db.lamp_works
+
+
[docs]defat_repeat(self):
+ """
+ Called every self.interval seconds. Makes the lamp in
+ the button blink.
+ """
+ self.obj.blink()
+
+
+
[docs]classDeactivateButtonEvent(DefaultScript):
+ """
+ This deactivates the button for a short while (it won't blink, won't
+ close its lid etc). It is meant to be called when the button is pushed
+ and run as long as the blinded effect lasts. We cannot put these methods
+ in the AddBlindedCmdSet script since that script is defined on the *account*
+ whereas this one must be defined on the *button*.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Sets things up.
+ """
+ self.key="deactivate_button"
+ self.desc="Deactivate red button temporarily"
+ self.interval=21# seconds
+ self.start_delay=True# wait with the first repeat for self.interval seconds.
+ self.persistent=True
+ self.repeats=1# only do this once
+
+
[docs]defat_start(self):
+ """
+ Deactivate the button. Observe that this method is always
+ called directly, regardless of the value of self.start_delay
+ (that just controls when at_repeat() is called)
+ """
+ # closing the lid will also add the ClosedState script
+ self.obj.close_lid()
+ # lock the lid so other accounts can't access it until the
+ # first one's effect has worn off.
+ self.obj.db.lid_locked=True
+ # breaking the lamp also sets a correct desc
+ self.obj.break_lamp(feedback=False)
+
+
[docs]defat_repeat(self):
+ """
+ When this is called, reset the functionality of the button.
+ """
+ # restore button's desc.
+
+ self.obj.db.lamp_works=True
+ desc="This is a large red button, inviting yet evil-looking. "
+ desc+="Its glass cover is closed, protecting it."
+ self.db.desc=desc
+ # re-activate the blink button event.
+ self.obj.scripts.add(BlinkButtonEvent)
+ # unlock the lid
+ self.obj.db.lid_locked=False
+ self.obj.scripts.validate()
[docs]deftearDown(self):
+ super(TestBodyFunctions,self).tearDown()
+ # if we forget to stop the script, DirtyReactorAggregateError will be raised
+ self.script.stop()
+
+
[docs]deftest_at_repeat(self,mock_random):
+ """test that no message will be sent when below the 66% threshold"""
+ mock_random.random=Mock(return_value=0.5)
+ old_func=self.script.send_random_message
+ self.script.send_random_message=Mock()
+ self.script.at_repeat()
+ self.script.send_random_message.assert_not_called()
+ # test that random message will be sent
+ mock_random.random=Mock(return_value=0.7)
+ self.script.at_repeat()
+ self.script.send_random_message.assert_called()
+ self.script.send_random_message=old_func
+
+
[docs]deftest_send_random_message(self,mock_random):
+ """Test that correct message is sent for each random value"""
+ old_func=self.char1.msg
+ self.char1.msg=Mock()
+ # test each of the values
+ mock_random.random=Mock(return_value=0.05)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with("You tap your foot, looking around.")
+ mock_random.random=Mock(return_value=0.15)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with("You have an itch. Hard to reach too.")
+ mock_random.random=Mock(return_value=0.25)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with(
+ "You think you hear someone behind you. ... ""but when you look there's noone there."
+ )
+ mock_random.random=Mock(return_value=0.35)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with("You inspect your fingernails. Nothing to report.")
+ mock_random.random=Mock(return_value=0.45)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with("You cough discreetly into your hand.")
+ mock_random.random=Mock(return_value=0.55)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with("You scratch your head, looking around.")
+ mock_random.random=Mock(return_value=0.65)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with("You blink, forgetting what it was you were going to do.")
+ mock_random.random=Mock(return_value=0.75)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with("You feel lonely all of a sudden.")
+ mock_random.random=Mock(return_value=0.85)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with("You get a great idea. Of course you won't tell anyone.")
+ mock_random.random=Mock(return_value=0.95)
+ self.script.send_random_message()
+ self.char1.msg.assert_called_with("You suddenly realize how much you love Evennia!")
+ self.char1.msg=old_func
Source code for evennia.contrib.tutorial_world.intro_menu
+"""
+Intro menu / game tutor
+
+Evennia contrib - Griatch 2020
+
+This contrib is an intro-menu for general MUD and evennia usage using the
+EvMenu menu-templating system.
+
+EvMenu templating is a way to create a menu using a string-format instead
+of creating all nodes manually. Of course, for full functionality one must
+still create the goto-callbacks.
+
+"""
+
+fromevenniaimportcreate_object
+fromevenniaimportCmdSet
+fromevennia.utils.evmenuimportparse_menu_template,EvMenu
+
+# Goto callbacks and helper resources for the menu
+
+
+
[docs]defdo_nothing(caller,raw_string,**kwargs):
+ """
+ Re-runs the current node
+ """
+ returnNone
+
+
+
[docs]defsend_testing_tagged(caller,raw_string,**kwargs):
+ """
+ Test to send a message to a pane tagged with 'testing' in the webclient.
+
+ """
+ caller.msg(
+ (
+ "This is a message tagged with 'testing' and "
+ "should appear in the pane you selected!\n "
+ f"You wrote: '{raw_string}'",
+ {"type":"testing"},
+ )
+ )
+ returnNone
+
+
+# Resources for the first help-command demo
+
+
+
[docs]classDemoCommandSetHelp(CmdSet):
+ """
+ Demo the help command
+ """
+
+ key="Help Demo Set"
+ priority=2
+
+
[docs]defgoto_command_demo_help(caller,raw_string,**kwargs):
+ "Sets things up before going to the help-demo node"
+ _maintain_demo_room(caller,delete=True)
+ caller.cmdset.remove(DemoCommandSetRoom)
+ caller.cmdset.remove(DemoCommandSetComms)
+ caller.cmdset.add(DemoCommandSetHelp)# TODO - make persistent
+ returnkwargs.get("gotonode")or"command_demo_help"
[docs]defgoto_command_demo_comms(caller,raw_string,**kwargs):
+ """
+ Setup and go to the color demo node.
+ """
+ caller.cmdset.remove(DemoCommandSetHelp)
+ caller.cmdset.remove(DemoCommandSetRoom)
+ caller.cmdset.add(DemoCommandSetComms)
+ returnkwargs.get("gotonode")or"comms_demo_start"
+
+
+# Resources for the room demo
+
+_ROOM_DESC="""
+This is a small and comfortable wood cabin. Bright sunlight is shining in
+through the windows.
+
+Use |ylook sign|n or |yl sign|n to examine the wooden sign nailed to the wall.
+
+"""
+
+_SIGN_DESC="""
+The small sign reads:
+
+ Good! Now try '|ylook small|n'.
+
+ ... You'll get a multi-match error! There are two things that 'small' could
+ refer to here - the 'small wooden sign' or the 'small, cozy cabin' itself. You will
+ get a list of the possibilities.
+
+ You could either tell Evennia which one you wanted by picking a unique part
+ of their name (like '|ylook cozy|n') or use the number in the list to pick
+ the one you want, like this:
+
+ |ylook 2-small|n
+
+ As long as what you write is uniquely identifying you can be lazy and not
+ write the full name of the thing you want to look at. Try '|ylook bo|n',
+ '|yl co|n' or '|yl 1-sm|n'!
+
+ ... Oh, and if you see database-ids like (#1245) by the name of objects,
+ it's because you are playing with Builder-privileges or higher. Regular
+ players will not see the numbers.
+
+ Next try |ylook door|n.
+
+"""
+
+_DOOR_DESC_OUT="""
+This is a solid wooden door leading to the outside of the cabin. Some
+text is written on it:
+
+ This is an |wexit|n. An exit is often named by its compass-direction like
+ |weast|n, |wwest|n, |wnorthwest|n and so on, but it could be named
+ anything, like this door. To use the exit, you just write its name. So by
+ writing |ydoor|n you will leave the cabin.
+
+"""
+
+_DOOR_DESC_IN="""
+This is a solid wooden door leading to the inside of the cabin. On
+are some carved text:
+
+ This exit leads back into the cabin. An exit is just like any object,
+ so while has a name, it can also have aliases. To get back inside
+ you can both write |ydoor|n but also |yin|n.
+
+"""
+
+_MEADOW_DESC="""
+This is a lush meadow, just outside a cozy cabin. It's surrounded
+by trees and sunlight filters down from a clear blue sky.
+
+There is a |wstone|n here. Try looking at it!
+
+"""
+
+_STONE_DESC="""
+This is a fist-sized stone covered in runes:
+
+ To pick me up, use
+
+ |yget stone|n
+
+ You can see what you carry with the |yinventory|n (|yi|n).
+
+ To drop me again, just write
+
+ |ydrop stone|n
+
+ Use |ynext|n when you are done exploring and want to
+ continue with the tutorial.
+
+"""
+
+
+def_maintain_demo_room(caller,delete=False):
+ """
+ Handle the creation/cleanup of demo assets. We store them
+ on the character and clean them when leaving the menu later.
+ """
+ # this is a tuple (room, obj)
+ roomdata=caller.db.tutorial_world_demo_room_data
+
+ ifdelete:
+ ifroomdata:
+ # we delete directly for simplicity. We need to delete
+ # in specific order to avoid deleting rooms moves
+ # its contents to their default home-location
+ prev_loc,room1,sign,room2,stone,door_out,door_in=roomdata
+ caller.location=prev_loc
+ sign.delete()
+ stone.delete()
+ door_out.delete()
+ door_in.delete()
+ room1.delete()
+ room2.delete()
+ delcaller.db.tutorial_world_demo_room_data
+ elifnotroomdata:
+ # create and describe the cabin and box
+ room1=create_object("evennia.objects.objects.DefaultRoom",key="A small, cozy cabin")
+ room1.db.desc=_ROOM_DESC.lstrip()
+ sign=create_object(
+ "evennia.objects.objects.DefaultObject",key="small wooden sign",location=room1
+ )
+ sign.db.desc=_SIGN_DESC.strip()
+ sign.locks.add("get:false()")
+ sign.db.get_err_msg="The sign is nailed to the wall. It's not budging."
+
+ # create and describe the meadow and stone
+ room2=create_object("evennia.objects.objects.DefaultRoom",key="A lush summer meadow")
+ room2.db.desc=_MEADOW_DESC.lstrip()
+ stone=create_object(
+ "evennia.objects.objects.DefaultObject",key="carved stone",location=room2
+ )
+ stone.db.desc=_STONE_DESC.strip()
+
+ # make the linking exits
+ door_out=create_object(
+ "evennia.objects.objects.DefaultExit",
+ key="Door",
+ location=room1,
+ destination=room2,
+ locks=["get:false()"],
+ )
+ door_out.db.desc=_DOOR_DESC_OUT.strip()
+ door_in=create_object(
+ "evennia.objects.objects.DefaultExit",
+ key="entrance to the cabin",
+ aliases=["door","in","entrance"],
+ location=room2,
+ destination=room1,
+ locks=["get:false()"],
+ )
+ door_in.db.desc=_DOOR_DESC_IN.strip()
+
+ # store references for easy removal later
+ caller.db.tutorial_world_demo_room_data=(
+ caller.location,
+ room1,
+ sign,
+ room2,
+ stone,
+ door_out,
+ door_in,
+ )
+ # move caller into room
+ caller.location=room1
+
+
+
[docs]defgoto_command_demo_room(caller,raw_string,**kwargs):
+ """
+ Setup and go to the demo-room node. Generates a little 2-room environment
+ for testing out some commands.
+ """
+ _maintain_demo_room(caller)
+ caller.cmdset.remove(DemoCommandSetHelp)
+ caller.cmdset.remove(DemoCommandSetComms)
+ caller.cmdset.add(DemoCommandSetRoom)
+ return"command_demo_room"
+
+
+# register all callables that can be used in the menu template
+
+GOTO_CALLABLES={
+ "send_testing_tagged":send_testing_tagged,
+ "do_nothing":do_nothing,
+ "goto_command_demo_help":goto_command_demo_help,
+ "goto_command_demo_comms":goto_command_demo_comms,
+ "goto_command_demo_room":goto_command_demo_room,
+ "goto_cleanup_cmdsets":goto_cleanup_cmdsets,
+}
+
+
+# Main menu definition
+
+MENU_TEMPLATE="""
+
+## NODE start
+
+|g** Evennia introduction wizard **|n
+
+If you feel lost you can learn some of the basics of how to play a text-based
+game here. You can also learn a little about the system and how to find more
+help. You can exit this tutorial-wizard at any time by entering '|yq|n' or '|yquit|n'.
+
+Press |y<return>|n or write |ynext|n to step forward. Or select a number to jump to.
+
+## OPTIONS
+
+ 1 (next);1;next;n: What is a MUD/MU*? -> about_muds
+ 2: About Evennia -> about_evennia
+ 3: Using the webclient -> using webclient
+ 4: The help command -> goto_command_demo_help()
+ 5: Communicating with others -> goto_command_demo_help(gotonode='talk on channels')
+ 6: Using colors -> goto_command_demo_comms(gotonode='testing_colors')
+ 7: Moving and exploring -> goto_command_demo_room()
+ 8: Conclusions & next steps-> conclusions
+ >: about_muds
+
+# ---------------------------------------------------------------------------------
+
+## NODE about_muds
+
+|g** About MUDs **|n
+
+The term '|wMUD|n' stands for Multi-user-Dungeon or -Dimension. A MUD is
+primarily played by inserting text |wcommands|n and getting text back.
+
+MUDS were the |wprecursors|n to graphical MMORPG-style games like World of
+Warcraft. While not as mainstream as they once were, comparing a text-game to a
+graphical game is like comparing a book to a movie - it's just a different
+experience altogether.
+
+MUDs are |wdifferent|n from Interactive Fiction (IF) in that they are multiplayer
+and usually has a consistent game world with many stories and protagonists
+acting at the same time.
+
+Like there are many different styles of graphical MMOs, there are |wmany
+variations|n of MUDs: They can be slow-paced or fast. They can cover fantasy,
+sci-fi, horror or other genres. They can allow PvP or not and be casual or
+hardcore, strategic, tactical, turn-based or play in real-time.
+
+Whereas 'MUD' is arguably the most well-known term, there are other terms
+centered around particular game engines - such as MUSH, MOO, MUX, MUCK, LPMuds,
+ROMs, Diku and others. Many people that played MUDs in the past used one of
+these existing families of text game-servers, whether they knew it or not.
+
+|cEvennia|n is a newer text game engine designed to emulate almost any existing
+gaming style you like and possibly any new ones you can come up with!
+
+## OPTIONS
+
+ next;n: About Evennia -> about_evennia
+ back to start;back;start;t: start
+ >: about_evennia
+
+# ---------------------------------------------------------------------------------
+
+## NODE about_evennia
+
+|g** About Evennia **|n
+
+|cEvennia|n is a Python game engine for creating multiplayer online text-games
+(aka MUDs, MUSHes, MUX, MOOs...). It is open-source and |wfree to use|n, also for
+commercial projects (BSD license).
+
+Out of the box, Evennia provides a |wworking, if empty game|n. Whereas you can play
+via traditional telnet MUD-clients, the server runs your game's website and
+offers a |wHTML5 webclient|n so that people can play your game in their browser
+without downloading anything extra.
+
+Evennia deliberately |wdoes not|n hard-code any game-specific things like
+combat-systems, races, skills, etc. They would not match what just you wanted
+anyway! Whereas we do have optional contribs with many examples, most of our
+users use them as inspiration to make their own thing.
+
+Evennia is developed entirely in |wPython|n, using modern developer practices.
+The advantage of text is that even a solo developer or small team can
+realistically make a competitive multiplayer game (as compared to a graphical
+MMORPG which is one of the most expensive game types in existence to develop).
+Many also use Evennia as a |wfun way to learn Python|n!
+
+## OPTIONS
+
+ next;n: Using the webclient -> using webclient
+ back;b: About MUDs -> about_muds
+ >: using webclient
+
+# ---------------------------------------------------------------------------------
+
+## NODE using webclient
+
+|g** Using the Webclient **|n
+
+|RNote: This is only relevant if you use Evennia's HTML5 web client. If you use a
+third-party (telnet) mud-client, you can skip this section.|n
+
+Evennia's web client is (for a local install) found by pointing your browser to
+
+ |yhttp://localhost:4001/webclient|n
+
+For a live example, the public Evennia demo can be found at
+
+ |yhttps://demo.evennia.com/webclient|n
+
+The web client starts out having two panes - the input-pane for entering commands
+and the main window.
+
+- Use |y<Return>|n (or click the arrow on the right) to send your input.
+- Use |yShift + <up/down-arrow>|n to step back and forth in your command-history.
+- Use |yShift + <Return>|n to add a new line to your input without sending.
+
+There is also some |wextra|n info to learn about customizing the webclient.
+
+## OPTIONS
+
+ extra: Customizing the webclient -> customizing the webclient
+ next;n: Playing the game -> goto_command_demo_help()
+ back;b: About Evennia -> about_evennia
+ back to start;start: start
+ >: goto_command_demo_help()
+
+# ---------------------------------------------------------------------------------
+
+# this is a dead-end 'leaf' of the menu
+
+## NODE customizing the webclient
+
+|g** Extra hints on customizing the Webclient **|n
+
+|y1)|n The panes of the webclient can be resized and you can create additional panes.
+
+- Press the little plus (|w+|n) sign in the top left and a new tab will appear.
+- Click and drag the tab and pull it far to the right and release when it creates two
+ panes next to each other.
+
+|y2)|n You can have certain server output only appear in certain panes.
+
+- In your new rightmost pane, click the diamond (⯁) symbol at the top.
+- Unselect everything and make sure to select "testing".
+- Click the diamond again so the menu closes.
+- Next, write "|ytest Hello world!|n". A test-text should appear in your rightmost pane!
+
+|y3)|n You can customize general webclient settings by pressing the cogwheel in the upper
+left corner. It allows to change things like font and if the client should play sound.
+
+The "message routing" allows for rerouting text matching a certain regular expression (regex)
+to a web client pane with a specific tag that you set yourself.
+
+|y4)|n Close the right-hand pane with the |wX|n in the rop right corner.
+
+## OPTIONS
+
+ back;b: using webclient
+ > test *: send tagged message to new pane -> send_testing_tagged()
+ >: using webclient
+
+# ---------------------------------------------------------------------------------
+
+# we get here via goto_command_demo_help()
+
+## NODE command_demo_help
+
+|g** Playing the game **|n
+
+Evennia has about |w90 default commands|n. They include useful administration/building
+commands and a few limited "in-game" commands to serve as examples. They are intended
+to be changed, extended and modified as you please.
+
+First to try is |yhelp|n. This lists all commands |wcurrently|n available to you.
+
+Use |yhelp <topic>|n to get specific help. Try |yhelp help|n to get help on using
+the help command. For your game you could add help about your game, lore, rules etc
+as well.
+
+At the moment you only have |whelp|n and some |wChannel Names|n (the '<menu commands>'
+is just a placeholder to indicate you are using this menu).
+
+We'll add more commands as we get to them in this tutorial - but we'll only
+cover a small handful. Once you exit you'll find a lot more! Now let's try
+those channels ...
+
+## OPTIONS
+
+ next;n: Talk on Channels -> talk on channels
+ back;b: Using the webclient -> goto_cleanup_cmdsets(gotonode='using webclient')
+ back to start;start: start
+ >: talk on channels
+
+# ---------------------------------------------------------------------------------
+
+## NODE talk on channels
+
+|g** Talk on Channels **|n
+
+|wChannels|n are like in-game chatrooms. The |wChannel Names|n help-category
+holds the names of the channels available to you right now. One such channel is
+|wpublic|n. Use |yhelp public|n to see how to use it. Try it:
+
+ |ypublic Hello World!|n
+
+This will send a message to the |wpublic|n channel where everyone on that
+channel can see it. If someone else is on your server, you may get a reply!
+
+Evennia can link its in-game channels to external chat networks. This allows
+you to talk with people not actually logged into the game. For
+example, the online Evennia-demo links its |wpublic|n channel to the #evennia
+IRC support channel.
+
+## OPTIONS
+
+ next;n: Talk to people in-game -> goto_command_demo_comms()
+ back;b: Finding help -> goto_command_demo_help()
+ back to start;start: start
+ >: goto_command_demo_comms()
+
+# ---------------------------------------------------------------------------------
+
+# we get here via goto_command_demo_comms()
+
+## NODE comms_demo_start
+
+|g** Talk to people in-game **|n
+
+You can also chat with people inside the game. If you try |yhelp|n now you'll
+find you have a few more commands available for trying this out.
+
+ |ysay Hello there!|n
+ |y'Hello there!|n
+
+|wsay|n is used to talk to people in the same location you are. Everyone in the
+room will see what you have to say. A single quote |y'|n is a convenient shortcut.
+
+ |ypose smiles|n
+ |y:smiles|n
+
+|wpose|n (or |wemote|n) describes what you do to those nearby. This is a very simple
+command by default, but it can be extended to much more complex parsing in order to
+include other people/objects in the emote, reference things by a short-description etc.
+
+## OPTIONS
+
+ next;n: Paging people -> paging_people
+ back;b: Talk on Channels -> goto_command_demo_help(gotonode='talk on channels')
+ back to start;start: start
+ >: paging_people
+
+# ---------------------------------------------------------------------------------
+
+## NODE paging_people
+
+|g** Paging people **|n
+
+Halfway between talking on a |wChannel|n and chatting in your current location
+with |wsay|n and |wpose|n, you can also |wpage|n people. This is like a private
+message only they can see.
+
+ |ypage <name> = Hello there!
+ page <name1>, <name2> = Hello both of you!|n
+
+If you are alone on the server, put your own name as |w<name>|n to test it and
+page yourself. Write just |ypage|n to see your latest pages. This will also show
+you if anyone paged you while you were offline.
+
+(By the way - depending on which games you are used to, you may think that the
+use of |y=|n above is strange. This is a MUSH/MUX-style of syntax. For your own
+game you can change the |wpose|n command to work however you prefer).
+
+## OPTIONS
+
+ next;n: Using colors -> testing_colors
+ back;b: Talk to people in-game -> comms_demo_start
+ back to start;start: start
+ >: testing_colors
+
+# ---------------------------------------------------------------------------------
+
+## NODE testing_colors
+
+|g** U|rs|yi|gn|wg |c|yc|wo|rl|bo|gr|cs |g**|n
+
+You can add color in your text by the help of tags. However, remember that not
+everyone will see your colors - it depends on their client (and some use
+screenreaders). Using color can also make text harder to read. So use it
+sparingly.
+
+To start coloring something |rred|n, add a ||r (red) marker and then
+end with ||n (to go back to neutral/no-color):
+
+ |ysay This is a ||rred||n text!
+ say This is a ||Rdark red||n text!|n
+
+You can also change the background:
+
+ |ysay This is a ||[x||bblue text on a light-grey background!|n
+
+There are 16 base colors and as many background colors (called ANSI colors). Some
+clients also supports so-called Xterm256 which gives a total of 256 colors. These are
+given as |w||rgb|n, where r, g, b are the components of red, green and blue from 0-5:
+
+ |ysay This is ||050solid green!|n
+ |ysay This is ||520an orange color!|n
+ |ysay This is ||[005||555white on bright blue background!|n
+
+If you don't see the expected colors from the above examples, it's because your
+client does not support it - try out the Evennia webclient instead. To see all
+color codes printed, try
+
+ |ycolor ansi
+ |ycolor xterm
+
+## OPTIONS
+
+ next;n: Moving and Exploring -> goto_command_demo_room()
+ back;b: Paging people -> goto_command_demo_comms(gotonode='paging_people')
+ back to start;start: start
+ >: goto_command_demo_room()
+
+# ---------------------------------------------------------------------------------
+
+# we get here via goto_command_demo_room()
+
+## NODE command_demo_room
+
+|gMoving and Exploring|n
+
+For exploring the game, a very important command is '|ylook|n'. It's also
+abbreviated '|yl|n' since it's used so much. Looking displays/redisplays your
+current location. You can also use it to look closer at items in the world. So
+far in this tutorial, using 'look' would just redisplay the menu.
+
+Try |ylook|n now. You have been quietly transported to a sunny cabin to look
+around in. Explore a little and use |ynext|n when you are done.
+
+## OPTIONS
+
+ next;n: Conclusions -> conclusions
+ back;b: Channel commands -> goto_command_demo_comms(gotonode='testing_colors')
+ back to start;start: start
+ >: conclusions
+
+# ---------------------------------------------------------------------------------
+
+## NODE conclusions
+
+|gConclusions|n
+
+That concludes this little quick-intro to using the base game commands of
+Evennia. With this you should be able to continue exploring and also find help
+if you get stuck!
+
+Write |ynext|n to end this wizard and continue to the tutorial-world quest!
+If you want there is also some |wextra|n info for where to go beyond that.
+
+## OPTIONS
+
+ extra: Where to go next -> post scriptum
+ next;next;n: End -> end
+ back;b: Moving and Exploring -> goto_command_demo_room()
+ back to start;start: start
+ >: end
+
+# ---------------------------------------------------------------------------------
+
+## NODE post scriptum
+
+|gWhere to next?|n
+
+After playing through the tutorial-world quest, if you aim to make a game with
+Evennia you are wise to take a look at the |wEvennia documentation|n at
+
+ |yhttps://www.evennia.com/docs/latest
+
+- You can start by trying to build some stuff by following the |wBuilder quick-start|n:
+
+ |yhttps://www.evennia.com/docs/latest/Building-Quickstart|n
+
+- The tutorial-world may or may not be your cup of tea, but it does show off
+ several |wuseful tools|n of Evennia. You may want to check out how it works:
+
+ |yhttps://www.evennia.com/docs/latest/Tutorial-World-Introduction|n
+
+- You can then continue looking through the |wTutorials|n and pick one that
+ fits your level of understanding.
+
+ |yhttps://www.evennia.com/docs/latest/Tutorials|n
+
+- Make sure to |wjoin our forum|n and connect to our |wsupport chat|n! The
+ Evennia community is very active and friendly and no question is too simple.
+ You will often quickly get help. You can everything you need linked from
+
+ |yhttps://www.evennia.com|n
+
+# ---------------------------------------------------------------------------------
+
+## OPTIONS
+
+back: conclusions
+>: conclusions
+
+
+## NODE end
+
+|gGood luck!|n
+
+"""
+
+
+# -------------------------------------------------------------------------------------------
+#
+# EvMenu implementation and access function
+#
+# -------------------------------------------------------------------------------------------
+
+
+
[docs]classTutorialEvMenu(EvMenu):
+ """
+ Custom EvMenu for displaying the intro-menu
+ """
+
+
Source code for evennia.contrib.tutorial_world.mob
+"""
+This module implements a simple mobile object with
+a very rudimentary AI as well as an aggressive enemy
+object based on that mobile class.
+
+"""
+
+importrandom
+
+fromevenniaimportTICKER_HANDLER
+fromevenniaimportsearch_object
+fromevenniaimportCommand,CmdSet
+fromevenniaimportlogger
+fromevennia.contrib.tutorial_worldimportobjectsastut_objects
+
+
+
[docs]classCmdMobOnOff(Command):
+ """
+ Activates/deactivates Mob
+
+ Usage:
+ mobon <mob>
+ moboff <mob>
+
+ This turns the mob from active (alive) mode
+ to inactive (dead) mode. It is used during
+ building to activate the mob once it's
+ prepared.
+ """
+
+ key="mobon"
+ aliases="moboff"
+ locks="cmd:superuser()"
+
+
[docs]classMob(tut_objects.TutorialObject):
+ """
+ This is a state-machine AI mobile. It has several states which are
+ controlled from setting various Attributes. All default to True:
+
+ patrolling: if set, the mob will move randomly
+ from room to room, but preferring to not return
+ the way it came. If unset, the mob will remain
+ stationary (idling) until attacked.
+ aggressive: if set, will attack Characters in
+ the same room using whatever Weapon it
+ carries (see tutorial_world.objects.Weapon).
+ if unset, the mob will never engage in combat
+ no matter what.
+ hunting: if set, the mob will pursue enemies trying
+ to flee from it, so it can enter combat. If unset,
+ it will return to patrolling/idling if fled from.
+ immortal: If set, the mob cannot take any damage.
+ irregular_echoes: list of strings the mob generates at irregular intervals.
+ desc_alive: the physical description while alive
+ desc_dead: the physical descripion while dead
+ send_defeated_to: unique key/alias for location to send defeated enemies to
+ defeat_msg: message to echo to defeated opponent
+ defeat_msg_room: message to echo to room. Accepts %s as the name of the defeated.
+ hit_msg: message to echo when this mob is hit. Accepts %s for the mob's key.
+ weapon_ineffective_msg: message to echo for useless attacks
+ death_msg: message to echo to room when this mob dies.
+ patrolling_pace: how many seconds per tick, when patrolling
+ aggressive_pace: -"- attacking
+ hunting_pace: -"- hunting
+ death_pace: -"- returning to life when dead
+
+ field 'home' - the home location should set to someplace inside
+ the patrolling area. The mob will use this if it should
+ happen to roam into a room with no exits.
+
+ """
+
+
[docs]defat_init(self):
+ """
+ When initialized from cache (after a server reboot), set up
+ the AI state.
+ """
+ # The AI state machine (not persistent).
+ self.ndb.is_patrolling=self.db.patrollingandnotself.db.is_dead
+ self.ndb.is_attacking=False
+ self.ndb.is_hunting=False
+ self.ndb.is_immortal=self.db.immortalorself.db.is_dead
+
+
[docs]defat_object_creation(self):
+ """
+ Called the first time the object is created.
+ We set up the base properties and flags here.
+ """
+ self.cmdset.add(MobCmdSet,permanent=True)
+ # Main AI flags. We start in dead mode so we don't have to
+ # chase the mob around when building.
+ self.db.patrolling=True
+ self.db.aggressive=True
+ self.db.immortal=False
+ # db-store if it is dead or not
+ self.db.is_dead=True
+ # specifies how much damage we divide away from non-magic weapons
+ self.db.damage_resistance=100.0
+ # pace (number of seconds between ticks) for
+ # the respective modes.
+ self.db.patrolling_pace=6
+ self.db.aggressive_pace=2
+ self.db.hunting_pace=1
+ self.db.death_pace=100# stay dead for 100 seconds
+
+ # we store the call to the tickerhandler
+ # so we can easily deactivate the last
+ # ticker subscription when we switch.
+ # since we will use the same idstring
+ # throughout we only need to save the
+ # previous interval we used.
+ self.db.last_ticker_interval=None
+
+ # store two separate descriptions, one for alive and
+ # one for dead (corpse)
+ self.db.desc_alive="This is a moving object."
+ self.db.desc_dead="A dead body."
+
+ # health stats
+ self.db.full_health=20
+ self.db.health=20
+
+ # when this mob defeats someone, we move the character off to
+ # some other place (Dark Cell in the tutorial).
+ self.db.send_defeated_to="dark cell"
+ # text to echo to the defeated foe.
+ self.db.defeat_msg="You fall to the ground."
+ self.db.defeat_msg_room="%s falls to the ground."
+ self.db.weapon_ineffective_msg=(
+ "Your weapon just passes through your enemy, causing almost no effect!"
+ )
+
+ self.db.death_msg="After the last hit %s evaporates."%self.key
+ self.db.hit_msg="%s wails, shudders and writhes."%self.key
+ self.db.irregular_msgs=["the enemy looks about.","the enemy changes stance."]
+
+ self.db.tutorial_info="This is an object with simple state AI, using a ticker to move."
+
+ def_set_ticker(self,interval,hook_key,stop=False):
+ """
+ Set how often the given hook key should
+ be "ticked".
+
+ Args:
+ interval (int): The number of seconds
+ between ticks
+ hook_key (str): The name of the method
+ (on this mob) to call every interval
+ seconds.
+ stop (bool, optional): Just stop the
+ last ticker without starting a new one.
+ With this set, the interval and hook_key
+ arguments are unused.
+
+ In order to only have one ticker
+ running at a time, we make sure to store the
+ previous ticker subscription so that we can
+ easily find and stop it before setting a
+ new one. The tickerhandler is persistent so
+ we need to remember this across reloads.
+
+ """
+ idstring="tutorial_mob"# this doesn't change
+ last_interval=self.db.last_ticker_interval
+ last_hook_key=self.db.last_hook_key
+ iflast_intervalandlast_hook_key:
+ # we have a previous subscription, kill this first.
+ TICKER_HANDLER.remove(
+ interval=last_interval,callback=getattr(self,last_hook_key),idstring=idstring
+ )
+ self.db.last_ticker_interval=interval
+ self.db.last_hook_key=hook_key
+ ifnotstop:
+ # set the new ticker
+ TICKER_HANDLER.add(
+ interval=interval,callback=getattr(self,hook_key),idstring=idstring
+ )
+
+ def_find_target(self,location):
+ """
+ Scan the given location for suitable targets (this is defined
+ as Characters) to attack. Will ignore superusers.
+
+ Args:
+ location (Object): the room to scan.
+
+ Returns:
+ The first suitable target found.
+
+ """
+ targets=[
+ obj
+ forobjinlocation.contents_get(exclude=self)
+ ifobj.has_accountandnotobj.is_superuser
+ ]
+ returntargets[0]iftargetselseNone
+
+
[docs]defset_alive(self,*args,**kwargs):
+ """
+ Set the mob to "alive" mode. This effectively
+ resurrects it from the dead state.
+ """
+ self.db.health=self.db.full_health
+ self.db.is_dead=False
+ self.db.desc=self.db.desc_alive
+ self.ndb.is_immortal=self.db.immortal
+ self.ndb.is_patrolling=self.db.patrolling
+ ifnotself.location:
+ self.move_to(self.home)
+ ifself.db.patrolling:
+ self.start_patrolling()
+
+
[docs]defset_dead(self):
+ """
+ Set the mob to "dead" mode. This turns it off
+ and makes sure it can take no more damage.
+ It also starts a ticker for when it will return.
+ """
+ self.db.is_dead=True
+ self.location=None
+ self.ndb.is_patrolling=False
+ self.ndb.is_attacking=False
+ self.ndb.is_hunting=False
+ self.ndb.is_immortal=True
+ # we shall return after some time
+ self._set_ticker(self.db.death_pace,"set_alive")
+
+
[docs]defstart_idle(self):
+ """
+ Starts just standing around. This will kill
+ the ticker and do nothing more.
+ """
+ self._set_ticker(None,None,stop=True)
+
+
[docs]defstart_patrolling(self):
+ """
+ Start the patrolling state by
+ registering us with the ticker-handler
+ at a leasurely pace.
+ """
+ ifnotself.db.patrolling:
+ self.start_idle()
+ return
+ self._set_ticker(self.db.patrolling_pace,"do_patrol")
+ self.ndb.is_patrolling=True
+ self.ndb.is_hunting=False
+ self.ndb.is_attacking=False
+ # for the tutorial, we also heal the mob in this mode
+ self.db.health=self.db.full_health
[docs]defdo_patrol(self,*args,**kwargs):
+ """
+ Called repeatedly during patrolling mode. In this mode, the
+ mob scans its surroundings and randomly chooses a viable exit.
+ One should lock exits with the traverse:has_account() lock in
+ order to block the mob from moving outside its area while
+ allowing account-controlled characters to move normally.
+ """
+ ifrandom.random()<0.01andself.db.irregular_msgs:
+ self.location.msg_contents(random.choice(self.db.irregular_msgs))
+ ifself.db.aggressive:
+ # first check if there are any targets in the room.
+ target=self._find_target(self.location)
+ iftarget:
+ self.start_attacking()
+ return
+ # no target found, look for an exit.
+ exits=[exiforexiinself.location.exitsifexi.access(self,"traverse")]
+ ifexits:
+ # randomly pick an exit
+ exit=random.choice(exits)
+ # move there.
+ self.move_to(exit.destination)
+ else:
+ # no exits! teleport to home to get away.
+ self.move_to(self.home)
+
+
[docs]defdo_hunting(self,*args,**kwargs):
+ """
+ Called regularly when in hunting mode. In hunting mode the mob
+ scans adjacent rooms for enemies and moves towards them to
+ attack if possible.
+ """
+ ifrandom.random()<0.01andself.db.irregular_msgs:
+ self.location.msg_contents(random.choice(self.db.irregular_msgs))
+ ifself.db.aggressive:
+ # first check if there are any targets in the room.
+ target=self._find_target(self.location)
+ iftarget:
+ self.start_attacking()
+ return
+ # no targets found, scan surrounding rooms
+ exits=[exiforexiinself.location.exitsifexi.access(self,"traverse")]
+ ifexits:
+ # scan the exits destination for targets
+ forexitinexits:
+ target=self._find_target(exit.destination)
+ iftarget:
+ # a target found. Move there.
+ self.move_to(exit.destination)
+ return
+ # if we get to this point we lost our
+ # prey. Resume patrolling.
+ self.start_patrolling()
+ else:
+ # no exits! teleport to home to get away.
+ self.move_to(self.home)
+
+
[docs]defdo_attack(self,*args,**kwargs):
+ """
+ Called regularly when in attacking mode. In attacking mode
+ the mob will bring its weapons to bear on any targets
+ in the room.
+ """
+ ifrandom.random()<0.01andself.db.irregular_msgs:
+ self.location.msg_contents(random.choice(self.db.irregular_msgs))
+ # first make sure we have a target
+ target=self._find_target(self.location)
+ ifnottarget:
+ # no target, start looking for one
+ self.start_hunting()
+ return
+
+ # we use the same attack commands as defined in
+ # tutorial_world.objects.Weapon, assuming that
+ # the mob is given a Weapon to attack with.
+ attack_cmd=random.choice(("thrust","pierce","stab","slash","chop"))
+ self.execute_cmd("%s%s"%(attack_cmd,target))
+
+ iftarget.db.healthisNone:
+ # This is not an attackable target
+ logger.log_err(f"{self.key} found {target} had an `health` attribute of `None`.")
+ return
+
+ # analyze the current state
+ iftarget.db.health<=0:
+ # we reduced the target to <= 0 health. Move them to the
+ # defeated room
+ target.msg(self.db.defeat_msg)
+ self.location.msg_contents(self.db.defeat_msg_room%target.key,exclude=target)
+ send_defeated_to=search_object(self.db.send_defeated_to)
+ ifsend_defeated_to:
+ target.move_to(send_defeated_to[0],quiet=True)
+ else:
+ logger.log_err(
+ "Mob: mob.db.send_defeated_to not found: %s"%self.db.send_defeated_to
+ )
+
+ # response methods - called by other objects
+
+
[docs]defat_hit(self,weapon,attacker,damage):
+ """
+ Someone landed a hit on us. Check our status
+ and start attacking if not already doing so.
+ """
+ ifself.db.healthisNone:
+ # health not set - this can't be damaged.
+ attacker.msg(self.db.weapon_ineffective_msg)
+ return
+
+ ifnotself.ndb.is_immortal:
+ ifnotweapon.db.magic:
+ # not a magic weapon - divide away magic resistance
+ damage/=self.db.damage_resistance
+ attacker.msg(self.db.weapon_ineffective_msg)
+ else:
+ self.location.msg_contents(self.db.hit_msg)
+ self.db.health-=damage
+
+ # analyze the result
+ ifself.db.health<=0:
+ # we are dead!
+ attacker.msg(self.db.death_msg)
+ self.set_dead()
+ else:
+ # still alive, start attack if not already attacking
+ ifself.db.aggressiveandnotself.ndb.is_attacking:
+ self.start_attacking()
+
+
[docs]defat_new_arrival(self,new_character):
+ """
+ This is triggered whenever a new character enters the room.
+ This is called by the TutorialRoom the mob stands in and
+ allows it to be aware of changes immediately without needing
+ to poll for them all the time. For example, the mob can react
+ right away, also when patrolling on a very slow ticker.
+ """
+ # the room actually already checked all we need, so
+ # we know it is a valid target.
+ ifself.db.aggressiveandnotself.ndb.is_attacking:
+ self.start_attacking()
Source code for evennia.contrib.tutorial_world.objects
+"""
+TutorialWorld - basic objects - Griatch 2011
+
+This module holds all "dead" object definitions for
+the tutorial world. Object-commands and -cmdsets
+are also defined here, together with the object.
+
+Objects:
+
+TutorialObject
+
+TutorialReadable
+TutorialClimbable
+Obelisk
+LightSource
+CrumblingWall
+Weapon
+WeaponRack
+
+"""
+
+importrandom
+
+fromevenniaimportDefaultObject,DefaultExit,Command,CmdSet
+fromevennia.utilsimportsearch,delay,dedent
+fromevennia.prototypes.spawnerimportspawn
+
+# -------------------------------------------------------------
+#
+# TutorialObject
+#
+# The TutorialObject is the base class for all items
+# in the tutorial. They have an attribute "tutorial_info"
+# on them that the global tutorial command can use to extract
+# interesting behind-the scenes information about the object.
+#
+# TutorialObjects may also be "reset". What the reset means
+# is up to the object. It can be the resetting of the world
+# itself, or the removal of an inventory item from a
+# character's inventory when leaving the tutorial, for example.
+#
+# -------------------------------------------------------------
+
+
+
[docs]classTutorialObject(DefaultObject):
+ """
+ This is the baseclass for all objects in the tutorial.
+ """
+
+
[docs]defat_object_creation(self):
+ """Called when the object is first created."""
+ super().at_object_creation()
+ self.db.tutorial_info="No tutorial info is available for this object."
+
+
[docs]defreset(self):
+ """Resets the object, whatever that may mean."""
+ self.location=self.home
+
+
+# -------------------------------------------------------------
+#
+# Readable - an object that can be "read"
+#
+# -------------------------------------------------------------
+
+#
+# Read command
+#
+
+
+
[docs]classCmdRead(Command):
+ """
+ Usage:
+ read [obj]
+
+ Read some text of a readable object.
+ """
+
+ key="read"
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """
+ Implements the read command. This simply looks for an
+ Attribute "readable_text" on the object and displays that.
+ """
+
+ ifself.args:
+ obj=self.caller.search(self.args.strip())
+ else:
+ obj=self.obj
+ ifnotobj:
+ return
+ # we want an attribute read_text to be defined.
+ readtext=obj.db.readable_text
+ ifreadtext:
+ string="You read |C%s|n:\n%s"%(obj.key,readtext)
+ else:
+ string="There is nothing to read on %s."%obj.key
+ self.caller.msg(string)
+
+
+
[docs]classCmdSetReadable(CmdSet):
+ """
+ A CmdSet for readables.
+ """
+
+
[docs]defat_cmdset_creation(self):
+ """
+ Called when the cmdset is created.
+ """
+ self.add(CmdRead())
+
+
+
[docs]classTutorialReadable(TutorialObject):
+ """
+ This simple object defines some attributes and
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called when object is created. We make sure to set the needed
+ Attribute and add the readable cmdset.
+ """
+ super().at_object_creation()
+ self.db.tutorial_info=(
+ "This is an object with a 'read' command defined in a command set on itself."
+ )
+ self.db.readable_text="There is no text written on %s."%self.key
+ # define a command on the object.
+ self.cmdset.add_default(CmdSetReadable,permanent=True)
+
+
+# -------------------------------------------------------------
+#
+# Climbable object
+#
+# The climbable object works so that once climbed, it sets
+# a flag on the climber to show that it was climbed. A simple
+# command 'climb' handles the actual climbing. The memory
+# of what was last climbed is used in a simple puzzle in the
+# tutorial.
+#
+# -------------------------------------------------------------
+
+
+
[docs]classCmdClimb(Command):
+ """
+ Climb an object
+
+ Usage:
+ climb <object>
+
+ This allows you to climb.
+ """
+
+ key="climb"
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """Implements function"""
+
+ ifnotself.args:
+ self.caller.msg("What do you want to climb?")
+ return
+ obj=self.caller.search(self.args.strip())
+ ifnotobj:
+ return
+ ifobj!=self.obj:
+ self.caller.msg("Try as you might, you cannot climb that.")
+ return
+ ostring=self.obj.db.climb_text
+ ifnotostring:
+ ostring="You climb %s. Having looked around, you climb down again."%self.obj.name
+ self.caller.msg(ostring)
+ # set a tag on the caller to remember that we climbed.
+ self.caller.tags.add("tutorial_climbed_tree",category="tutorial_world")
[docs]classTutorialClimbable(TutorialObject):
+ """
+ A climbable object. All that is special about it is that it has
+ the "climb" command available on it.
+ """
+
+
[docs]defat_object_creation(self):
+ """Called at initial creation only"""
+ self.cmdset.add_default(CmdSetClimbable,permanent=True)
+
+
+# -------------------------------------------------------------
+#
+# Obelisk - a unique item
+#
+# The Obelisk is an object with a modified return_appearance method
+# that causes it to look slightly different every time one looks at it.
+# Since what you actually see is a part of a game puzzle, the act of
+# looking also stores a key attribute on the looking object (different
+# depending on which text you saw) for later reference.
+#
+# -------------------------------------------------------------
+
+
+
[docs]classObelisk(TutorialObject):
+ """
+ This object changes its description randomly, and which is shown
+ determines which order "clue id" is stored on the Character for
+ future puzzles.
+
+ Important Attribute:
+ puzzle_descs (list): list of descriptions. One of these is
+ picked randomly when this object is looked at and its index
+ in the list is used as a key for to solve the puzzle.
+
+ """
+
+
[docs]defat_object_creation(self):
+ """Called when object is created."""
+ super().at_object_creation()
+ self.db.tutorial_info=(
+ "This object changes its desc randomly, and makes sure to remember which one you saw."
+ )
+ self.db.puzzle_descs=["You see a normal stone slab"]
+ # make sure this can never be picked up
+ self.locks.add("get:false()")
+
+
[docs]defreturn_appearance(self,caller):
+ """
+ This hook is called by the look command to get the description
+ of the object. We overload it with our own version.
+ """
+ # randomly get the index for one of the descriptions
+ descs=self.db.puzzle_descs
+ clueindex=random.randint(0,len(descs)-1)
+ # set this description, with the random extra
+ string=(
+ "The surface of the obelisk seem to waver, shift and writhe under your gaze, with "
+ "different scenes and structures appearing whenever you look at it. "
+ )
+ self.db.desc=string+descs[clueindex]
+ # remember that this was the clue we got. The Puzzle room will
+ # look for this later to determine if you should be teleported
+ # or not.
+ caller.db.puzzle_clue=clueindex
+ # call the parent function as normal (this will use
+ # the new desc Attribute we just set)
+ returnsuper().return_appearance(caller)
+
+
+# -------------------------------------------------------------
+#
+# LightSource
+#
+# This object emits light. Once it has been turned on it
+# cannot be turned off. When it burns out it will delete
+# itself.
+#
+# This could be implemented using a single-repeat Script or by
+# registering with the TickerHandler. We do it simpler by
+# using the delay() utility function. This is very simple
+# to use but does not survive a server @reload. Because of
+# where the light matters (in the Dark Room where you can
+# find new light sources easily), this is okay here.
+#
+# -------------------------------------------------------------
+
+
+
[docs]classCmdLight(Command):
+ """
+ Creates light where there was none. Something to burn.
+ """
+
+ key="on"
+ aliases=["light","burn"]
+ # only allow this command if command.obj is carried by caller.
+ locks="cmd:holds()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """
+ Implements the light command. Since this command is designed
+ to sit on a "lightable" object, we operate only on self.obj.
+ """
+
+ ifself.obj.light():
+ self.caller.msg("You light %s."%self.obj.key)
+ self.caller.location.msg_contents(
+ "%s lights %s!"%(self.caller,self.obj.key),exclude=[self.caller]
+ )
+ else:
+ self.caller.msg("%s is already burning."%self.obj.key)
+
+
+
[docs]classCmdSetLight(CmdSet):
+ """CmdSet for the lightsource commands"""
+
+ key="lightsource_cmdset"
+ # this is higher than the dark cmdset - important!
+ priority=3
+
+
[docs]defat_cmdset_creation(self):
+ """called at cmdset creation"""
+ self.add(CmdLight())
+
+
+
[docs]classLightSource(TutorialObject):
+ """
+ This implements a light source object.
+
+ When burned out, the object will be deleted.
+ """
+
+
[docs]defat_init(self):
+ """
+ If this is called with the Attribute is_giving_light already
+ set, we know that the timer got killed by a server
+ reload/reboot before it had time to finish. So we kill it here
+ instead. This is the price we pay for the simplicity of the
+ non-persistent delay() method.
+ """
+ ifself.db.is_giving_light:
+ self.delete()
+
+
[docs]defat_object_creation(self):
+ """Called when object is first created."""
+ super().at_object_creation()
+ self.db.tutorial_info=(
+ "This object can be lit to create light. It has a timeout for how long it burns."
+ )
+ self.db.is_giving_light=False
+ self.db.burntime=60*3# 3 minutes
+ # this is the default desc, it can of course be customized
+ # when created.
+ self.db.desc="A splinter of wood with remnants of resin on it, enough for burning."
+ # add the Light command
+ self.cmdset.add_default(CmdSetLight,permanent=True)
+
+ def_burnout(self):
+ """
+ This is called when this light source burns out. We make no
+ use of the return value.
+ """
+ # delete ourselves from the database
+ self.db.is_giving_light=False
+ try:
+ self.location.location.msg_contents(
+ "%s's %s flickers and dies."%(self.location,self.key),exclude=self.location
+ )
+ self.location.msg("Your %s flickers and dies."%self.key)
+ self.location.location.check_light_state()
+ exceptAttributeError:
+ try:
+ self.location.msg_contents("A %s on the floor flickers and dies."%self.key)
+ self.location.location.check_light_state()
+ exceptAttributeError:
+ # Mainly happens if we happen to be in a None location
+ pass
+ self.delete()
+
+
[docs]deflight(self):
+ """
+ Light this object - this is called by Light command.
+ """
+ ifself.db.is_giving_light:
+ returnFalse
+ # burn for 3 minutes before calling _burnout
+ self.db.is_giving_light=True
+ # if we are in a dark room, trigger its light check
+ try:
+ self.location.location.check_light_state()
+ exceptAttributeError:
+ try:
+ # maybe we are directly in the room
+ self.location.check_light_state()
+ exceptAttributeError:
+ # we are in a None location
+ pass
+ finally:
+ # start the burn timer. When it runs out, self._burnout
+ # will be called. We store the deferred so it can be
+ # killed in unittesting.
+ self.deferred=delay(60*3,self._burnout)
+ returnTrue
+
+
+# -------------------------------------------------------------
+#
+# Crumbling wall - unique exit
+#
+# This implements a simple puzzle exit that needs to be
+# accessed with commands before one can get to traverse it.
+#
+# The puzzle-part is simply to move roots (that have
+# presumably covered the wall) aside until a button for a
+# secret door is revealed. The original position of the
+# roots blocks the button, so they have to be moved to a certain
+# position - when they have, the "press button" command
+# is made available and the Exit is made traversable.
+#
+# -------------------------------------------------------------
+
+# There are four roots - two horizontal and two vertically
+# running roots. Each can have three positions: top/middle/bottom
+# and left/middle/right respectively. There can be any number of
+# roots hanging through the middle position, but only one each
+# along the sides. The goal is to make the center position clear.
+# (yes, it's really as simple as it sounds, just move the roots
+# to each side to "win". This is just a tutorial, remember?)
+#
+# The ShiftRoot command depends on the root object having an
+# Attribute root_pos (a dictionary) to describe the current
+# position of the roots.
+
+
+
[docs]classCmdShiftRoot(Command):
+ """
+ Shifts roots around.
+
+ Usage:
+ shift blue root left/right
+ shift red root left/right
+ shift yellow root up/down
+ shift green root up/down
+
+ """
+
+ key="shift"
+ aliases=["shiftroot","push","pull","move"]
+ # we only allow to use this command while the
+ # room is properly lit, so we lock it to the
+ # setting of Attribute "is_lit" on our location.
+ locks="cmd:locattr(is_lit)"
+ help_category="TutorialWorld"
+
+
[docs]defparse(self):
+ """
+ Custom parser; split input by spaces for simplicity.
+ """
+ self.arglist=self.args.strip().split()
+
+
[docs]deffunc(self):
+ """
+ Implement the command.
+ blue/red - vertical roots
+ yellow/green - horizontal roots
+ """
+
+ ifnotself.arglist:
+ self.caller.msg("What do you want to move, and in what direction?")
+ return
+
+ if"root"inself.arglist:
+ # we clean out the use of the word "root"
+ self.arglist.remove("root")
+
+ # we accept arguments on the form <color> <direction>
+
+ ifnotlen(self.arglist)>1:
+ self.caller.msg(
+ "You must define which colour of root you want to move, and in which direction."
+ )
+ return
+
+ color=self.arglist[0].lower()
+ direction=self.arglist[1].lower()
+
+ # get current root positions dict
+ root_pos=self.obj.db.root_pos
+
+ ifcolornotinroot_pos:
+ self.caller.msg("No such root to move.")
+ return
+
+ # first, vertical roots (red/blue) - can be moved left/right
+ ifcolor=="red":
+ ifdirection=="left":
+ root_pos[color]=max(-1,root_pos[color]-1)
+ self.caller.msg("You shift the reddish root to the left.")
+ ifroot_pos[color]!=0androot_pos[color]==root_pos["blue"]:
+ root_pos["blue"]+=1
+ self.caller.msg(
+ "The root with blue flowers gets in the way and is pushed to the right."
+ )
+ elifdirection=="right":
+ root_pos[color]=min(1,root_pos[color]+1)
+ self.caller.msg("You shove the reddish root to the right.")
+ ifroot_pos[color]!=0androot_pos[color]==root_pos["blue"]:
+ root_pos["blue"]-=1
+ self.caller.msg(
+ "The root with blue flowers gets in the way and is pushed to the left."
+ )
+ else:
+ self.caller.msg(
+ "The root hangs straight down - you can only move it left or right."
+ )
+ elifcolor=="blue":
+ ifdirection=="left":
+ root_pos[color]=max(-1,root_pos[color]-1)
+ self.caller.msg("You shift the root with small blue flowers to the left.")
+ ifroot_pos[color]!=0androot_pos[color]==root_pos["red"]:
+ root_pos["red"]+=1
+ self.caller.msg(
+ "The reddish root is too big to fit as well, so that one falls away to the left."
+ )
+ elifdirection=="right":
+ root_pos[color]=min(1,root_pos[color]+1)
+ self.caller.msg("You shove the root adorned with small blue flowers to the right.")
+ ifroot_pos[color]!=0androot_pos[color]==root_pos["red"]:
+ root_pos["red"]-=1
+ self.caller.msg(
+ "The thick reddish root gets in the way and is pushed back to the left."
+ )
+ else:
+ self.caller.msg(
+ "The root hangs straight down - you can only move it left or right."
+ )
+
+ # now the horizontal roots (yellow/green). They can be moved up/down
+ elifcolor=="yellow":
+ ifdirection=="up":
+ root_pos[color]=max(-1,root_pos[color]-1)
+ self.caller.msg("You shift the root with small yellow flowers upwards.")
+ ifroot_pos[color]!=0androot_pos[color]==root_pos["green"]:
+ root_pos["green"]+=1
+ self.caller.msg("The green weedy root falls down.")
+ elifdirection=="down":
+ root_pos[color]=min(1,root_pos[color]+1)
+ self.caller.msg("You shove the root adorned with small yellow flowers downwards.")
+ ifroot_pos[color]!=0androot_pos[color]==root_pos["green"]:
+ root_pos["green"]-=1
+ self.caller.msg("The weedy green root is shifted upwards to make room.")
+ else:
+ self.caller.msg("The root hangs across the wall - you can only move it up or down.")
+ elifcolor=="green":
+ ifdirection=="up":
+ root_pos[color]=max(-1,root_pos[color]-1)
+ self.caller.msg("You shift the weedy green root upwards.")
+ ifroot_pos[color]!=0androot_pos[color]==root_pos["yellow"]:
+ root_pos["yellow"]+=1
+ self.caller.msg("The root with yellow flowers falls down.")
+ elifdirection=="down":
+ root_pos[color]=min(1,root_pos[color]+1)
+ self.caller.msg("You shove the weedy green root downwards.")
+ ifroot_pos[color]!=0androot_pos[color]==root_pos["yellow"]:
+ root_pos["yellow"]-=1
+ self.caller.msg(
+ "The root with yellow flowers gets in the way and is pushed upwards."
+ )
+ else:
+ self.caller.msg("The root hangs across the wall - you can only move it up or down.")
+
+ # we have moved the root. Store new position
+ self.obj.db.root_pos=root_pos
+
+ # Check victory condition
+ iflist(root_pos.values()).count(0)==0:# no roots in middle position
+ # This will affect the cmd: lock of CmdPressButton
+ self.obj.db.button_exposed=True
+ self.caller.msg("Holding aside the root you think you notice something behind it ...")
+
+
+
[docs]classCmdPressButton(Command):
+ """
+ Presses a button.
+ """
+
+ key="press"
+ aliases=["press button","button","push button"]
+ # only accessible if the button was found and there is light. This checks
+ # the Attribute button_exposed on the Wall object so that
+ # you can only push the button when the puzzle is solved. It also
+ # checks the is_lit Attribute on the location.
+ locks="cmd:objattr(button_exposed) and objlocattr(is_lit)"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """Implements the command"""
+
+ ifself.caller.db.crumbling_wall_found_exit:
+ # we already pushed the button
+ self.caller.msg(
+ "The button folded away when the secret passage opened. You cannot push it again."
+ )
+ return
+
+ # pushing the button
+ string=(
+ "You move your fingers over the suspicious depression, then gives it a "
+ "decisive push. First nothing happens, then there is a rumble and a hidden "
+ "|wpassage|n opens, dust and pebbles rumbling as part of the wall moves aside."
+ )
+ self.caller.msg(string)
+ string=(
+ "%s moves their fingers over the suspicious depression, then gives it a "
+ "decisive push. First nothing happens, then there is a rumble and a hidden "
+ "|wpassage|n opens, dust and pebbles rumbling as part of the wall moves aside."
+ )
+ self.caller.location.msg_contents(string%self.caller.key,exclude=self.caller)
+ ifnotself.obj.open_wall():
+ self.caller.msg("The exit leads nowhere, there's just more stone behind it ...")
+
+
+
[docs]classCmdSetCrumblingWall(CmdSet):
+ """Group the commands for crumblingWall"""
+
+ key="crumblingwall_cmdset"
+ priority=2
+
+
[docs]defat_cmdset_creation(self):
+ """called when object is first created."""
+ self.add(CmdShiftRoot())
+ self.add(CmdPressButton())
+
+
+
[docs]classCrumblingWall(TutorialObject,DefaultExit):
+ """
+ This is a custom Exit.
+
+ The CrumblingWall can be examined in various ways, but only if a
+ lit light source is in the room. The traversal itself is blocked
+ by a traverse: lock on the exit that only allows passage if a
+ certain attribute is set on the trying account.
+
+ Important attribute
+ destination - this property must be set to make this a valid exit
+ whenever the button is pushed (this hides it as an exit
+ until it actually is)
+ """
+
+
[docs]defat_init(self):
+ """
+ Called when object is recalled from cache.
+ """
+ self.reset()
+
+
[docs]defat_object_creation(self):
+ """called when the object is first created."""
+ super().at_object_creation()
+
+ self.aliases.add(["secret passage","passage","crack","opening","secret"])
+
+ # starting root positions. H1/H2 are the horizontally hanging roots,
+ # V1/V2 the vertically hanging ones. Each can have three positions:
+ # (-1, 0, 1) where 0 means the middle position. yellow/green are
+ # horizontal roots and red/blue vertical, all may have value 0, but n
+ # ever any other identical value.
+ self.db.root_pos={"yellow":0,"green":0,"red":0,"blue":0}
+
+ # flags controlling the puzzle victory conditions
+ self.db.button_exposed=False
+ self.db.exit_open=False
+
+ # this is not even an Exit until it has a proper destination, and we won't assign
+ # that until it is actually open. Until then we store the destination here. This
+ # should be given a reasonable value at creation!
+ self.db.destination="#2"
+
+ # we lock this Exit so that one can only execute commands on it
+ # if its location is lit and only traverse it once the Attribute
+ # exit_open is set to True.
+ self.locks.add("cmd:locattr(is_lit);traverse:objattr(exit_open)")
+ # set cmdset
+ self.cmdset.add(CmdSetCrumblingWall,permanent=True)
+
+
[docs]defopen_wall(self):
+ """
+ This method is called by the push button command once the puzzle
+ is solved. It opens the wall and sets a timer for it to reset
+ itself.
+ """
+ # this will make it into a proper exit (this returns a list)
+ eloc=search.search_object(self.db.destination)
+ ifnoteloc:
+ returnFalse
+ else:
+ self.destination=eloc[0]
+ self.db.exit_open=True
+ # start a 45 second timer before closing again. We store the deferred so it can be
+ # killed in unittesting.
+ self.deferred=delay(45,self.reset)
+ returnTrue
+
+ def_translate_position(self,root,ipos):
+ """Translates the position into words"""
+ rootnames={
+ "red":"The |rreddish|n vertical-hanging root ",
+ "blue":"The thick vertical root with |bblue|n flowers ",
+ "yellow":"The thin horizontal-hanging root with |yyellow|n flowers ",
+ "green":"The weedy |ggreen|n horizontal root ",
+ }
+ vpos={
+ -1:"hangs far to the |wleft|n on the wall.",
+ 0:"hangs straight down the |wmiddle|n of the wall.",
+ 1:"hangs far to the |wright|n of the wall.",
+ }
+ hpos={
+ -1:"covers the |wupper|n part of the wall.",
+ 0:"passes right over the |wmiddle|n of the wall.",
+ 1:"nearly touches the floor, near the |wbottom|n of the wall.",
+ }
+
+ ifrootin("yellow","green"):
+ string=rootnames[root]+hpos[ipos]
+ else:
+ string=rootnames[root]+vpos[ipos]
+ returnstring
+
+
[docs]defreturn_appearance(self,caller):
+ """
+ This is called when someone looks at the wall. We need to echo the
+ current root positions.
+ """
+ ifself.db.button_exposed:
+ # we found the button by moving the roots
+ result=[
+ "Having moved all the roots aside, you find that the center of the wall, "
+ "previously hidden by the vegetation, hid a curious square depression. It was maybe once "
+ "concealed and made to look a part of the wall, but with the crumbling of stone around it, "
+ "it's now easily identifiable as some sort of button."
+ ]
+ elifself.db.exit_open:
+ # we pressed the button; the exit is open
+ result=[
+ "With the button pressed, a crack has opened in the root-covered wall, just wide enough "
+ "to squeeze through. A cold draft is coming from the hole and you get the feeling the "
+ "opening may close again soon."
+ ]
+ else:
+ # puzzle not solved yet.
+ result=[
+ "The wall is old and covered with roots that here and there have permeated the stone. "
+ "The roots (or whatever they are - some of them are covered in small nondescript flowers) "
+ "crisscross the wall, making it hard to clearly see its stony surface. Maybe you could "
+ "try to |wshift|n or |wmove|n them (like '|wshift red up|n').\n"
+ ]
+ # display the root positions to help with the puzzle
+ forkey,posinself.db.root_pos.items():
+ result.append("\n"+self._translate_position(key,pos))
+ self.db.desc="".join(result)
+
+ # call the parent to continue execution (will use the desc we just set)
+ returnsuper().return_appearance(caller)
+
+
[docs]defat_after_traverse(self,traverser,source_location):
+ """
+ This is called after we traversed this exit. Cleans up and resets
+ the puzzle.
+ """
+ deltraverser.db.crumbling_wall_found_buttothe
+ deltraverser.db.crumbling_wall_found_exit
+ self.reset()
+
+
[docs]defat_failed_traverse(self,traverser):
+ """This is called if the account fails to pass the Exit."""
+ traverser.msg("No matter how you try, you cannot force yourself through %s."%self.key)
+
+
[docs]defreset(self):
+ """
+ Called by tutorial world runner, or whenever someone successfully
+ traversed the Exit.
+ """
+ self.location.msg_contents(
+ "The secret door closes abruptly, roots falling back into place."
+ )
+
+ # reset the flags and remove the exit destination
+ self.db.button_exposed=False
+ self.db.exit_open=False
+ self.destination=None
+
+ # Reset the roots with some random starting positions for the roots:
+ start_pos=[
+ {"yellow":1,"green":0,"red":0,"blue":0},
+ {"yellow":0,"green":0,"red":0,"blue":0},
+ {"yellow":0,"green":1,"red":-1,"blue":0},
+ {"yellow":1,"green":0,"red":0,"blue":0},
+ {"yellow":0,"green":0,"red":0,"blue":1},
+ ]
+ self.db.root_pos=random.choice(start_pos)
+
+
+# -------------------------------------------------------------
+#
+# Weapon - object type
+#
+# A weapon is necessary in order to fight in the tutorial
+# world. A weapon (which here is assumed to be a bladed
+# melee weapon for close combat) has three commands,
+# stab, slash and defend. Weapons also have a property "magic"
+# to determine if they are usable against certain enemies.
+#
+# Since Characters don't have special skills in the tutorial,
+# we let the weapon itself determine how easy/hard it is
+# to hit with it, and how much damage it can do.
+#
+# -------------------------------------------------------------
+
+
+
[docs]classCmdAttack(Command):
+ """
+ Attack the enemy. Commands:
+
+ stab <enemy>
+ slash <enemy>
+ parry
+
+ stab - (thrust) makes a lot of damage but is harder to hit with.
+ slash - is easier to land, but does not make as much damage.
+ parry - forgoes your attack but will make you harder to hit on next
+ enemy attack.
+
+ """
+
+ # this is an example of implementing many commands as a single
+ # command class, using the given command alias to separate between them.
+
+ key="attack"
+ aliases=[
+ "hit",
+ "kill",
+ "fight",
+ "thrust",
+ "pierce",
+ "stab",
+ "slash",
+ "chop",
+ "bash",
+ "parry",
+ "defend",
+ ]
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """Implements the stab"""
+
+ cmdstring=self.cmdstring
+
+ ifcmdstringin("attack","fight"):
+ string="How do you want to fight? Choose one of 'stab', 'slash' or 'defend'."
+ self.caller.msg(string)
+ return
+
+ # parry mode
+ ifcmdstringin("parry","defend"):
+ string=(
+ "You raise your weapon in a defensive pose, ready to block the next enemy attack."
+ )
+ self.caller.msg(string)
+ self.caller.db.combat_parry_mode=True
+ self.caller.location.msg_contents(
+ "%s takes a defensive stance"%self.caller,exclude=[self.caller]
+ )
+ return
+
+ ifnotself.args:
+ self.caller.msg("Who do you attack?")
+ return
+ target=self.caller.search(self.args.strip())
+ ifnottarget:
+ return
+
+ ifcmdstringin("thrust","pierce","stab"):
+ hit=float(self.obj.db.hit)*0.7# modified due to stab
+ damage=self.obj.db.damage*2# modified due to stab
+ string="You stab with %s. "%self.obj.key
+ tstring="%s stabs at you with %s. "%(self.caller.key,self.obj.key)
+ ostring="%s stabs at %s with %s. "%(self.caller.key,target.key,self.obj.key)
+ self.caller.db.combat_parry_mode=False
+ elifcmdstringin("slash","chop","bash"):
+ hit=float(self.obj.db.hit)# un modified due to slash
+ damage=self.obj.db.damage# un modified due to slash
+ string="You slash with %s. "%self.obj.key
+ tstring="%s slash at you with %s. "%(self.caller.key,self.obj.key)
+ ostring="%s slash at %s with %s. "%(self.caller.key,target.key,self.obj.key)
+ self.caller.db.combat_parry_mode=False
+ else:
+ self.caller.msg(
+ "You fumble with your weapon, unsure of whether to stab, slash or parry ..."
+ )
+ self.caller.location.msg_contents(
+ "%s fumbles with their weapon."%self.caller,exclude=self.caller
+ )
+ self.caller.db.combat_parry_mode=False
+ return
+
+ iftarget.db.combat_parry_mode:
+ # target is defensive; even harder to hit!
+ target.msg("|GYou defend, trying to avoid the attack.|n")
+ hit*=0.5
+
+ ifrandom.random()<=hit:
+ self.caller.msg(string+"|gIt's a hit!|n")
+ target.msg(tstring+"|rIt's a hit!|n")
+ self.caller.location.msg_contents(
+ ostring+"It's a hit!",exclude=[target,self.caller]
+ )
+
+ # call enemy hook
+ ifhasattr(target,"at_hit"):
+ # should return True if target is defeated, False otherwise.
+ target.at_hit(self.obj,self.caller,damage)
+ return
+ eliftarget.db.health:
+ target.db.health-=damage
+ else:
+ # sorry, impossible to fight this enemy ...
+ self.caller.msg("The enemy seems unaffected.")
+ return
+ else:
+ self.caller.msg(string+"|rYou miss.|n")
+ target.msg(tstring+"|gThey miss you.|n")
+ self.caller.location.msg_contents(ostring+"They miss.",exclude=[target,self.caller])
+
+
+
[docs]classCmdSetWeapon(CmdSet):
+ """Holds the attack command."""
+
+
[docs]defat_cmdset_creation(self):
+ """called at first object creation."""
+ self.add(CmdAttack())
+
+
+
[docs]classWeapon(TutorialObject):
+ """
+ This defines a bladed weapon.
+
+ Important attributes (set at creation):
+ hit - chance to hit (0-1)
+ parry - chance to parry (0-1)
+ damage - base damage given (modified by hit success and
+ type of attack) (0-10)
+
+ """
+
+
[docs]defat_object_creation(self):
+ """Called at first creation of the object"""
+ super().at_object_creation()
+ self.db.hit=0.4# hit chance
+ self.db.parry=0.8# parry chance
+ self.db.damage=1.0
+ self.db.magic=False
+ self.cmdset.add_default(CmdSetWeapon,permanent=True)
+
+
[docs]defreset(self):
+ """
+ When reset, the weapon is simply deleted, unless it has a place
+ to return to.
+ """
+ ifself.location.has_accountandself.home==self.location:
+ self.location.msg_contents(
+ "%s suddenly and magically fades into nothingness, as if it was never there ..."
+ %self.key
+ )
+ self.delete()
+ else:
+ self.location=self.home
+
+
+# -------------------------------------------------------------
+#
+# Weapon rack - spawns weapons
+#
+# This is a spawner mechanism that creates custom weapons from a
+# spawner prototype dictionary. Note that we only create a single typeclass
+# (Weapon) yet customize all these different weapons using the spawner.
+# The spawner dictionaries could easily sit in separate modules and be
+# used to create unique and interesting variations of typeclassed
+# objects.
+#
+# -------------------------------------------------------------
+
+WEAPON_PROTOTYPES={
+ "weapon":{
+ "typeclass":"evennia.contrib.tutorial_world.objects.Weapon",
+ "key":"Weapon",
+ "hit":0.2,
+ "parry":0.2,
+ "damage":1.0,
+ "magic":False,
+ "desc":"A generic blade.",
+ },
+ "knife":{
+ "prototype_parent":"weapon",
+ "aliases":"sword",
+ "key":"Kitchen knife",
+ "desc":"A rusty kitchen knife. Better than nothing.",
+ "damage":3,
+ },
+ "dagger":{
+ "prototype_parent":"knife",
+ "key":"Rusty dagger",
+ "aliases":["knife","dagger"],
+ "desc":"A double-edged dagger with a nicked edge and a wooden handle.",
+ "hit":0.25,
+ },
+ "sword":{
+ "prototype_parent":"weapon",
+ "key":"Rusty sword",
+ "aliases":["sword"],
+ "desc":"A rusty shortsword. It has a leather-wrapped handle covered i food grease.",
+ "hit":0.3,
+ "damage":5,
+ "parry":0.5,
+ },
+ "club":{
+ "prototype_parent":"weapon",
+ "key":"Club",
+ "desc":"A heavy wooden club, little more than a heavy branch.",
+ "hit":0.4,
+ "damage":6,
+ "parry":0.2,
+ },
+ "axe":{
+ "prototype_parent":"weapon",
+ "key":"Axe",
+ "desc":"A woodcutter's axe with a keen edge.",
+ "hit":0.4,
+ "damage":6,
+ "parry":0.2,
+ },
+ "ornate longsword":{
+ "prototype_parent":"sword",
+ "key":"Ornate longsword",
+ "desc":"A fine longsword with some swirling patterns on the handle.",
+ "hit":0.5,
+ "magic":True,
+ "damage":5,
+ },
+ "warhammer":{
+ "prototype_parent":"club",
+ "key":"Silver Warhammer",
+ "aliases":["hammer","warhammer","war"],
+ "desc":"A heavy war hammer with silver ornaments. This huge weapon causes massive damage - if you can hit.",
+ "hit":0.4,
+ "magic":True,
+ "damage":8,
+ },
+ "rune axe":{
+ "prototype_parent":"axe",
+ "key":"Runeaxe",
+ "aliases":["axe"],
+ "hit":0.4,
+ "magic":True,
+ "damage":6,
+ },
+ "thruning":{
+ "prototype_parent":"ornate longsword",
+ "key":"Broadsword named Thruning",
+ "desc":"This heavy bladed weapon is marked with the name 'Thruning'. It is very powerful in skilled hands.",
+ "hit":0.6,
+ "parry":0.6,
+ "damage":7,
+ },
+ "slayer waraxe":{
+ "prototype_parent":"rune axe",
+ "key":"Slayer waraxe",
+ "aliases":["waraxe","war","slayer"],
+ "desc":"A huge double-bladed axe marked with the runes for 'Slayer'."
+ " It has more runic inscriptions on its head, which you cannot decipher.",
+ "hit":0.7,
+ "damage":8,
+ },
+ "ghostblade":{
+ "prototype_parent":"ornate longsword",
+ "key":"The Ghostblade",
+ "aliases":["blade","ghost"],
+ "desc":"This massive sword is large as you are tall, yet seems to weigh almost nothing."
+ " It's almost like it's not really there.",
+ "hit":0.9,
+ "parry":0.8,
+ "damage":10,
+ },
+ "hawkblade":{
+ "prototype_parent":"ghostblade",
+ "key":"The Hawkblade",
+ "aliases":["hawk","blade"],
+ "desc":"The weapon of a long-dead heroine and a more civilized age,"
+ " the hawk-shaped hilt of this blade almost has a life of its own.",
+ "hit":0.85,
+ "parry":0.7,
+ "damage":11,
+ },
+}
+
+
+
[docs]classCmdGetWeapon(Command):
+ """
+ Usage:
+ get weapon
+
+ This will try to obtain a weapon from the container.
+ """
+
+ key="get weapon"
+ aliases="get weapon"
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """
+ Get a weapon from the container. It will
+ itself handle all messages.
+ """
+ self.obj.produce_weapon(self.caller)
+
+
+
[docs]classCmdSetWeaponRack(CmdSet):
+ """
+ The cmdset for the rack.
+ """
+
+ key="weaponrack_cmdset"
+
+
[docs]defat_cmdset_creation(self):
+ """Called at first creation of cmdset"""
+ self.add(CmdGetWeapon())
+
+
+
[docs]classWeaponRack(TutorialObject):
+ """
+ This object represents a weapon store. When people use the
+ "get weapon" command on this rack, it will produce one
+ random weapon from among those registered to exist
+ on it. This will also set a property on the character
+ to make sure they can't get more than one at a time.
+
+ Attributes to set on this object:
+ available_weapons: list of prototype-keys from
+ WEAPON_PROTOTYPES, the weapons available in this rack.
+ no_more_weapons_msg - error message to return to accounts
+ who already got one weapon from the rack and tries to
+ grab another one.
+
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ called at creation
+ """
+ self.cmdset.add_default(CmdSetWeaponRack,permanent=True)
+ self.db.rack_id="weaponrack_1"
+ # these are prototype names from the prototype
+ # dictionary above.
+ self.db.get_weapon_msg=dedent(
+ """
+ You find |c%s|n. While carrying this weapon, these actions are available:
+ |wstab/thrust/pierce <target>|n - poke at the enemy. More damage but harder to hit.
+ |wslash/chop/bash <target>|n - swipe at the enemy. Less damage but easier to hit.
+ |wdefend/parry|n - protect yourself and make yourself harder to hit.)
+ """
+ ).strip()
+
+ self.db.no_more_weapons_msg="you find nothing else of use."
+ self.db.available_weapons=["knife","dagger","sword","club"]
+
+
[docs]defproduce_weapon(self,caller):
+ """
+ This will produce a new weapon from the rack,
+ assuming the caller hasn't already gotten one. When
+ doing so, the caller will get Tagged with the id
+ of this rack, to make sure they cannot keep
+ pulling weapons from it indefinitely.
+ """
+ rack_id=self.db.rack_id
+ ifcaller.tags.get(rack_id,category="tutorial_world"):
+ caller.msg(self.db.no_more_weapons_msg)
+ else:
+ prototype=random.choice(self.db.available_weapons)
+ # use the spawner to create a new Weapon from the
+ # spawner dictionary, tag the caller
+ wpn=spawn(WEAPON_PROTOTYPES[prototype],prototype_parents=WEAPON_PROTOTYPES)[0]
+ caller.tags.add(rack_id,category="tutorial_world")
+ wpn.location=caller
+ caller.msg(self.db.get_weapon_msg%wpn.key)
Source code for evennia.contrib.tutorial_world.rooms
+"""
+
+Room Typeclasses for the TutorialWorld.
+
+This defines special types of Rooms available in the tutorial. To keep
+everything in one place we define them together with the custom
+commands needed to control them. Those commands could also have been
+in a separate module (e.g. if they could have been re-used elsewhere.)
+
+"""
+
+
+importrandom
+fromevenniaimportTICKER_HANDLER
+fromevenniaimportCmdSet,Command,DefaultRoom
+fromevenniaimportutils,create_object,search_object
+fromevenniaimportsyscmdkeys,default_cmds
+fromevennia.contrib.tutorial_world.objectsimportLightSource
+
+# the system error-handling module is defined in the settings. We load the
+# given setting here using utils.object_from_module. This way we can use
+# it regardless of if we change settings later.
+fromdjango.confimportsettings
+
+_SEARCH_AT_RESULT=utils.object_from_module(settings.SEARCH_AT_RESULT)
+
+# -------------------------------------------------------------
+#
+# Tutorial room - parent room class
+#
+# This room is the parent of all rooms in the tutorial.
+# It defines a tutorial command on itself (available to
+# all those who are in a tutorial room).
+#
+# -------------------------------------------------------------
+
+#
+# Special command available in all tutorial rooms
+
+
+
[docs]classCmdTutorial(Command):
+ """
+ Get help during the tutorial
+
+ Usage:
+ tutorial [obj]
+
+ This command allows you to get behind-the-scenes info
+ about an object or the current location.
+
+ """
+
+ key="tutorial"
+ aliases=["tut"]
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """
+ All we do is to scan the current location for an Attribute
+ called `tutorial_info` and display that.
+ """
+
+ caller=self.caller
+
+ ifnotself.args:
+ target=self.obj# this is the room the command is defined on
+ else:
+ target=caller.search(self.args.strip())
+ ifnottarget:
+ return
+ helptext=target.db.tutorial_infoor""
+
+ ifhelptext:
+ helptext=f" |G{helptext}|n"
+ else:
+ helptext=" |RSorry, there is no tutorial help available here.|n"
+ helptext+="\n\n (Write 'give up' if you want to abandon your quest.)"
+ caller.msg(helptext)
+
+
+# for the @detail command we inherit from MuxCommand, since
+# we want to make use of MuxCommand's pre-parsing of '=' in the
+# argument.
+
[docs]classCmdTutorialSetDetail(default_cmds.MuxCommand):
+ """
+ sets a detail on a room
+
+ Usage:
+ @detail <key> = <description>
+ @detail <key>;<alias>;... = description
+
+ Example:
+ @detail walls = The walls are covered in ...
+ @detail castle;ruin;tower = The distant ruin ...
+
+ This sets a "detail" on the object this command is defined on
+ (TutorialRoom for this tutorial). This detail can be accessed with
+ the TutorialRoomLook command sitting on TutorialRoom objects (details
+ are set as a simple dictionary on the room). This is a Builder command.
+
+ We custom parse the key for the ;-separator in order to create
+ multiple aliases to the detail all at once.
+ """
+
+ key="@detail"
+ locks="cmd:perm(Builder)"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """
+ All this does is to check if the object has
+ the set_detail method and uses it.
+ """
+ ifnotself.argsornotself.rhs:
+ self.caller.msg("Usage: @detail key = description")
+ return
+ ifnothasattr(self.obj,"set_detail"):
+ self.caller.msg("Details cannot be set on %s."%self.obj)
+ return
+ forkeyinself.lhs.split(";"):
+ # loop over all aliases, if any (if not, this will just be
+ # the one key to loop over)
+ self.obj.set_detail(key,self.rhs)
+ self.caller.msg("Detail set: '%s': '%s'"%(self.lhs,self.rhs))
+
+
+
[docs]classCmdTutorialLook(default_cmds.CmdLook):
+ """
+ looks at the room and on details
+
+ Usage:
+ look <obj>
+ look <room detail>
+ look *<account>
+
+ Observes your location, details at your location or objects
+ in your vicinity.
+
+ Tutorial: This is a child of the default Look command, that also
+ allows us to look at "details" in the room. These details are
+ things to examine and offers some extra description without
+ actually having to be actual database objects. It uses the
+ return_detail() hook on TutorialRooms for this.
+ """
+
+ # we don't need to specify key/locks etc, this is already
+ # set by the parent.
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """
+ Handle the looking. This is a copy of the default look
+ code except for adding in the details.
+ """
+ caller=self.caller
+ args=self.args
+ ifargs:
+ # we use quiet=True to turn off automatic error reporting.
+ # This tells search that we want to handle error messages
+ # ourself. This also means the search function will always
+ # return a list (with 0, 1 or more elements) rather than
+ # result/None.
+ looking_at_obj=caller.search(
+ args,
+ # note: excludes room/room aliases
+ candidates=caller.location.contents+caller.contents,
+ use_nicks=True,
+ quiet=True,
+ )
+ iflen(looking_at_obj)!=1:
+ # no target found or more than one target found (multimatch)
+ # look for a detail that may match
+ detail=self.obj.return_detail(args)
+ ifdetail:
+ self.caller.msg(detail)
+ return
+ else:
+ # no detail found, delegate our result to the normal
+ # error message handler.
+ _SEARCH_AT_RESULT(looking_at_obj,caller,args)
+ return
+ else:
+ # we found a match, extract it from the list and carry on
+ # normally with the look handling.
+ looking_at_obj=looking_at_obj[0]
+
+ else:
+ looking_at_obj=caller.location
+ ifnotlooking_at_obj:
+ caller.msg("You have no location to look at!")
+ return
+
+ ifnothasattr(looking_at_obj,"return_appearance"):
+ # this is likely due to us having an account instead
+ looking_at_obj=looking_at_obj.character
+ ifnotlooking_at_obj.access(caller,"view"):
+ caller.msg("Could not find '%s'."%args)
+ return
+ # get object's appearance
+ caller.msg(looking_at_obj.return_appearance(caller))
+ # the object's at_desc() method.
+ looking_at_obj.at_desc(looker=caller)
+ return
+
+
+
[docs]classCmdTutorialGiveUp(default_cmds.MuxCommand):
+ """
+ Give up the tutorial-world quest and return to Limbo, the start room of the
+ server.
+
+ """
+
+ key="give up"
+ aliases=["abort"]
+
+
[docs]deffunc(self):
+ outro_room=OutroRoom.objects.all()
+ ifoutro_room:
+ outro_room=outro_room[0]
+ else:
+ self.caller.msg(
+ "That didn't work (seems like a bug). "
+ "Try to use the |wteleport|n command instead."
+ )
+ return
+
+ self.caller.move_to(outro_room)
+
+
+
[docs]classTutorialRoomCmdSet(CmdSet):
+ """
+ Implements the simple tutorial cmdset. This will overload the look
+ command in the default CharacterCmdSet since it has a higher
+ priority (ChracterCmdSet has prio 0)
+ """
+
+ key="tutorial_cmdset"
+ priority=1
+
+
[docs]classTutorialRoom(DefaultRoom):
+ """
+ This is the base room type for all rooms in the tutorial world.
+ It defines a cmdset on itself for reading tutorial info about the location.
+ """
+
+
[docs]defat_object_creation(self):
+ """Called when room is first created"""
+ self.db.tutorial_info=(
+ "This is a tutorial room. It allows you to use the 'tutorial' command."
+ )
+ self.cmdset.add_default(TutorialRoomCmdSet)
+
+
[docs]defat_object_receive(self,new_arrival,source_location):
+ """
+ When an object enter a tutorial room we tell other objects in
+ the room about it by trying to call a hook on them. The Mob object
+ uses this to cheaply get notified of enemies without having
+ to constantly scan for them.
+
+ Args:
+ new_arrival (Object): the object that just entered this room.
+ source_location (Object): the previous location of new_arrival.
+
+ """
+ ifnew_arrival.has_accountandnotnew_arrival.is_superuser:
+ # this is a character
+ forobjinself.contents_get(exclude=new_arrival):
+ ifhasattr(obj,"at_new_arrival"):
+ obj.at_new_arrival(new_arrival)
+
+
[docs]defreturn_detail(self,detailkey):
+ """
+ This looks for an Attribute "obj_details" and possibly
+ returns the value of it.
+
+ Args:
+ detailkey (str): The detail being looked at. This is
+ case-insensitive.
+
+ """
+ details=self.db.details
+ ifdetails:
+ returndetails.get(detailkey.lower(),None)
+
+
[docs]defset_detail(self,detailkey,description):
+ """
+ This sets a new detail, using an Attribute "details".
+
+ Args:
+ detailkey (str): The detail identifier to add (for
+ aliases you need to add multiple keys to the
+ same description). Case-insensitive.
+ description (str): The text to return when looking
+ at the given detailkey.
+
+ """
+ ifself.db.details:
+ self.db.details[detailkey.lower()]=description
+ else:
+ self.db.details={detailkey.lower():description}
+
+
+# -------------------------------------------------------------
+#
+# Weather room - room with a ticker
+#
+# -------------------------------------------------------------
+
+# These are rainy weather strings
+WEATHER_STRINGS=(
+ "The rain coming down from the iron-grey sky intensifies.",
+ "A gust of wind throws the rain right in your face. Despite your cloak you shiver.",
+ "The rainfall eases a bit and the sky momentarily brightens.",
+ "For a moment it looks like the rain is slowing, then it begins anew with renewed force.",
+ "The rain pummels you with large, heavy drops. You hear the rumble of thunder in the distance.",
+ "The wind is picking up, howling around you, throwing water droplets in your face. It's cold.",
+ "Bright fingers of lightning flash over the sky, moments later followed by a deafening rumble.",
+ "It rains so hard you can hardly see your hand in front of you. You'll soon be drenched to the bone.",
+ "Lightning strikes in several thundering bolts, striking the trees in the forest to your west.",
+ "You hear the distant howl of what sounds like some sort of dog or wolf.",
+ "Large clouds rush across the sky, throwing their load of rain over the world.",
+)
+
+
+
[docs]classWeatherRoom(TutorialRoom):
+ """
+ This should probably better be called a rainy room...
+
+ This sets up an outdoor room typeclass. At irregular intervals,
+ the effects of weather will show in the room. Outdoor rooms should
+ inherit from this.
+
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called when object is first created.
+ We set up a ticker to update this room regularly.
+
+ Note that we could in principle also use a Script to manage
+ the ticking of the room; the TickerHandler works fine for
+ simple things like this though.
+ """
+ super().at_object_creation()
+ # subscribe ourselves to a ticker to repeatedly call the hook
+ # "update_weather" on this object. The interval is randomized
+ # so as to not have all weather rooms update at the same time.
+ self.db.interval=random.randint(50,70)
+ TICKER_HANDLER.add(
+ interval=self.db.interval,callback=self.update_weather,idstring="tutorial"
+ )
+ # this is parsed by the 'tutorial' command on TutorialRooms.
+ self.db.tutorial_info="This room has a Script running that has it echo a weather-related message at irregular intervals."
+
+
[docs]defupdate_weather(self,*args,**kwargs):
+ """
+ Called by the tickerhandler at regular intervals. Even so, we
+ only update 20% of the time, picking a random weather message
+ when we do. The tickerhandler requires that this hook accepts
+ any arguments and keyword arguments (hence the *args, **kwargs
+ even though we don't actually use them in this example)
+ """
+ ifrandom.random()<0.2:
+ # only update 20 % of the time
+ self.msg_contents("|w%s|n"%random.choice(WEATHER_STRINGS))
+
+
+SUPERUSER_WARNING=(
+ "\nWARNING: You are playing as a superuser ({name}). Use the {quell} command to\n"
+ "play without superuser privileges (many functions and puzzles ignore the \n"
+ "presence of a superuser, making this mode useful for exploring things behind \n"
+ "the scenes later).\n"
+)
+
+# ------------------------------------------------------------
+#
+# Intro Room - unique room
+#
+# This room marks the start of the tutorial. It sets up properties on
+# the player char that is needed for the tutorial.
+#
+# -------------------------------------------------------------
+
+
+
[docs]defat_object_creation(self):
+ """
+ Called when the room is first created.
+ """
+ super().at_object_creation()
+ self.db.tutorial_info=(
+ "The first room of the tutorial. "
+ "This assigns the health Attribute to "
+ "the account."
+ )
+ self.cmdset.add(CmdSetEvenniaIntro,permanent=True)
+
+
[docs]defat_object_receive(self,character,source_location):
+ """
+ Assign properties on characters
+ """
+
+ # setup character for the tutorial
+ health=self.db.char_healthor20
+
+ ifcharacter.has_account:
+ character.db.health=health
+ character.db.health_max=health
+
+ ifcharacter.is_superuser:
+ string="-"*78+SUPERUSER_WARNING+"-"*78
+ character.msg("|r%s|n"%string.format(name=character.key,quell="|wquell|r"))
+ else:
+ # quell user
+ ifcharacter.account:
+ character.account.execute_cmd("quell")
+ character.msg("(Auto-quelling while in tutorial-world)")
+
+
+# -------------------------------------------------------------
+#
+# Bridge - unique room
+#
+# Defines a special west-eastward "bridge"-room, a large room that takes
+# several steps to cross. It is complete with custom commands and a
+# chance of falling off the bridge. This room has no regular exits,
+# instead the exitings are handled by custom commands set on the account
+# upon first entering the room.
+#
+# Since one can enter the bridge room from both ends, it is
+# divided into five steps:
+# westroom <- 0 1 2 3 4 -> eastroom
+#
+# -------------------------------------------------------------
+
+
+
[docs]classCmdEast(Command):
+ """
+ Go eastwards across the bridge.
+
+ Tutorial info:
+ This command relies on the caller having two Attributes
+ (assigned by the room when entering):
+ - east_exit: a unique name or dbref to the room to go to
+ when exiting east.
+ - west_exit: a unique name or dbref to the room to go to
+ when exiting west.
+ The room must also have the following Attributes
+ - tutorial_bridge_posistion: the current position on
+ on the bridge, 0 - 4.
+
+ """
+
+ key="east"
+ aliases=["e"]
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """move one step eastwards"""
+ caller=self.caller
+
+ bridge_step=min(5,caller.db.tutorial_bridge_position+1)
+
+ ifbridge_step>4:
+ # we have reached the far east end of the bridge.
+ # Move to the east room.
+ eexit=search_object(self.obj.db.east_exit)
+ ifeexit:
+ caller.move_to(eexit[0])
+ else:
+ caller.msg("No east exit was found for this room. Contact an admin.")
+ return
+ caller.db.tutorial_bridge_position=bridge_step
+ # since we are really in one room, we have to notify others
+ # in the room when we move.
+ caller.location.msg_contents(
+ "%s steps eastwards across the bridge."%caller.name,exclude=caller
+ )
+ caller.execute_cmd("look")
+
+
+# go back across the bridge
+
[docs]classCmdWest(Command):
+ """
+ Go westwards across the bridge.
+
+ Tutorial info:
+ This command relies on the caller having two Attributes
+ (assigned by the room when entering):
+ - east_exit: a unique name or dbref to the room to go to
+ when exiting east.
+ - west_exit: a unique name or dbref to the room to go to
+ when exiting west.
+ The room must also have the following property:
+ - tutorial_bridge_posistion: the current position on
+ on the bridge, 0 - 4.
+
+ """
+
+ key="west"
+ aliases=["w"]
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """move one step westwards"""
+ caller=self.caller
+
+ bridge_step=max(-1,caller.db.tutorial_bridge_position-1)
+
+ ifbridge_step<0:
+ # we have reached the far west end of the bridge.
+ # Move to the west room.
+ wexit=search_object(self.obj.db.west_exit)
+ ifwexit:
+ caller.move_to(wexit[0])
+ else:
+ caller.msg("No west exit was found for this room. Contact an admin.")
+ return
+ caller.db.tutorial_bridge_position=bridge_step
+ # since we are really in one room, we have to notify others
+ # in the room when we move.
+ caller.location.msg_contents(
+ "%s steps westwards across the bridge."%caller.name,exclude=caller
+ )
+ caller.execute_cmd("look")
+
+
+BRIDGE_POS_MESSAGES=(
+ "You are standing |wvery close to the the bridge's western foundation|n."
+ " If you go west you will be back on solid ground ...",
+ "The bridge slopes precariously where it extends eastwards"
+ " towards the lowest point - the center point of the hang bridge.",
+ "You are |whalfways|n out on the unstable bridge.",
+ "The bridge slopes precariously where it extends westwards"
+ " towards the lowest point - the center point of the hang bridge.",
+ "You are standing |wvery close to the bridge's eastern foundation|n."
+ " If you go east you will be back on solid ground ...",
+)
+BRIDGE_MOODS=(
+ "The bridge sways in the wind.",
+ "The hanging bridge creaks dangerously.",
+ "You clasp the ropes firmly as the bridge sways and creaks under you.",
+ "From the castle you hear a distant howling sound, like that of a large dog or other beast.",
+ "The bridge creaks under your feet. Those planks does not seem very sturdy.",
+ "Far below you the ocean roars and throws its waves against the cliff,"
+ " as if trying its best to reach you.",
+ "Parts of the bridge come loose behind you, falling into the chasm far below!",
+ "A gust of wind causes the bridge to sway precariously.",
+ "Under your feet a plank comes loose, tumbling down. For a moment you dangle over the abyss ...",
+ "The section of rope you hold onto crumble in your hands,"
+ " parts of it breaking apart. You sway trying to regain balance.",
+)
+
+FALL_MESSAGE=(
+ "Suddenly the plank you stand on gives way under your feet! You fall!"
+ "\nYou try to grab hold of an adjoining plank, but all you manage to do is to "
+ "divert your fall westwards, towards the cliff face. This is going to hurt ... "
+ "\n ... The world goes dark ...\n\n"
+)
+
+
+
[docs]classCmdLookBridge(Command):
+ """
+ looks around at the bridge.
+
+ Tutorial info:
+ This command assumes that the room has an Attribute
+ "fall_exit", a unique name or dbref to the place they end upp
+ if they fall off the bridge.
+ """
+
+ key="look"
+ aliases=["l"]
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """Looking around, including a chance to fall."""
+ caller=self.caller
+ bridge_position=self.caller.db.tutorial_bridge_position
+ # this command is defined on the room, so we get it through self.obj
+ location=self.obj
+ # randomize the look-echo
+ message="|c%s|n\n%s\n%s"%(
+ location.key,
+ BRIDGE_POS_MESSAGES[bridge_position],
+ random.choice(BRIDGE_MOODS),
+ )
+
+ chars=[objforobjinself.obj.contents_get(exclude=caller)ifobj.has_account]
+ ifchars:
+ # we create the You see: message manually here
+ message+="\n You see: %s"%", ".join("|c%s|n"%char.keyforcharinchars)
+ self.caller.msg(message)
+
+ # there is a chance that we fall if we are on the western or central
+ # part of the bridge.
+ ifbridge_position<3andrandom.random()<0.05andnotself.caller.is_superuser:
+ # we fall 5% of time.
+ fall_exit=search_object(self.obj.db.fall_exit)
+ iffall_exit:
+ self.caller.msg("|r%s|n"%FALL_MESSAGE)
+ self.caller.move_to(fall_exit[0],quiet=True)
+ # inform others on the bridge
+ self.obj.msg_contents(
+ "A plank gives way under %s's feet and "
+ "they fall from the bridge!"%self.caller.key
+ )
+
+
+# custom help command
+
[docs]classCmdBridgeHelp(Command):
+ """
+ Overwritten help command while on the bridge.
+ """
+
+ key="help"
+ aliases=["h","?"]
+ locks="cmd:all()"
+ help_category="Tutorial world"
+
+
[docs]deffunc(self):
+ """Implements the command."""
+ string=(
+ "You are trying hard not to fall off the bridge ..."
+ "\n\nWhat you can do is trying to cross the bridge |weast|n"
+ " or try to get back to the mainland |wwest|n)."
+ )
+ self.caller.msg(string)
+
+
+
[docs]classBridgeCmdSet(CmdSet):
+ """This groups the bridge commands. We will store it on the room."""
+
+ key="Bridge commands"
+ priority=2# this gives it precedence over the normal look/help commands.
+
+
[docs]defat_cmdset_creation(self):
+ """Called at first cmdset creation"""
+ self.add(CmdTutorial())
+ self.add(CmdEast())
+ self.add(CmdWest())
+ self.add(CmdLookBridge())
+ self.add(CmdBridgeHelp())
+
+
+BRIDGE_WEATHER=(
+ "The rain intensifies, making the planks of the bridge even more slippery.",
+ "A gust of wind throws the rain right in your face.",
+ "The rainfall eases a bit and the sky momentarily brightens.",
+ "The bridge shakes under the thunder of a closeby thunder strike.",
+ "The rain pummels you with large, heavy drops. You hear the distinct howl of a large hound in the distance.",
+ "The wind is picking up, howling around you and causing the bridge to sway from side to side.",
+ "Some sort of large bird sweeps by overhead, giving off an eery screech. Soon it has disappeared in the gloom.",
+ "The bridge sways from side to side in the wind.",
+ "Below you a particularly large wave crashes into the rocks.",
+ "From the ruin you hear a distant, otherwordly howl. Or maybe it was just the wind.",
+)
+
+
+
[docs]classBridgeRoom(WeatherRoom):
+ """
+ The bridge room implements an unsafe bridge. It also enters the player into
+ a state where they get new commands so as to try to cross the bridge.
+
+ We want this to result in the account getting a special set of
+ commands related to crossing the bridge. The result is that it
+ will take several steps to cross it, despite it being represented
+ by only a single room.
+
+ We divide the bridge into steps:
+
+ self.db.west_exit - - | - - self.db.east_exit
+ 0 1 2 3 4
+
+ The position is handled by a variable stored on the character
+ when entering and giving special move commands will
+ increase/decrease the counter until the bridge is crossed.
+
+ We also has self.db.fall_exit, which points to a gathering
+ location to end up if we happen to fall off the bridge (used by
+ the CmdLookBridge command).
+
+ """
+
+
[docs]defat_object_creation(self):
+ """Setups the room"""
+ # this will start the weather room's ticker and tell
+ # it to call update_weather regularly.
+ super().at_object_creation()
+ # this identifies the exits from the room (should be the command
+ # needed to leave through that exit). These are defaults, but you
+ # could of course also change them after the room has been created.
+ self.db.west_exit="cliff"
+ self.db.east_exit="gate"
+ self.db.fall_exit="cliffledge"
+ # add the cmdset on the room.
+ self.cmdset.add(BridgeCmdSet,permanent=True)
+ # since the default Character's at_look() will access the room's
+ # return_description (this skips the cmdset) when
+ # first entering it, we need to explicitly turn off the room
+ # as a normal view target - once inside, our own look will
+ # handle all return messages.
+ self.locks.add("view:false()")
+
+
[docs]defupdate_weather(self,*args,**kwargs):
+ """
+ This is called at irregular intervals and makes the passage
+ over the bridge a little more interesting.
+ """
+ ifrandom.random()<80:
+ # send a message most of the time
+ self.msg_contents("|w%s|n"%random.choice(BRIDGE_WEATHER))
+
+
[docs]defat_object_receive(self,character,source_location):
+ """
+ This hook is called by the engine whenever the player is moved
+ into this room.
+ """
+ ifcharacter.has_account:
+ # we only run this if the entered object is indeed a player object.
+ # check so our east/west exits are correctly defined.
+ wexit=search_object(self.db.west_exit)
+ eexit=search_object(self.db.east_exit)
+ fexit=search_object(self.db.fall_exit)
+ ifnot(wexitandeexitandfexit):
+ character.msg(
+ "The bridge's exits are not properly configured. "
+ "Contact an admin. Forcing west-end placement."
+ )
+ character.db.tutorial_bridge_position=0
+ return
+ ifsource_location==eexit[0]:
+ # we assume we enter from the same room we will exit to
+ character.db.tutorial_bridge_position=4
+ else:
+ # if not from the east, then from the west!
+ character.db.tutorial_bridge_position=0
+ character.execute_cmd("look")
+
+
[docs]defat_object_leave(self,character,target_location):
+ """
+ This is triggered when the player leaves the bridge room.
+ """
+ ifcharacter.has_account:
+ # clean up the position attribute
+ delcharacter.db.tutorial_bridge_position
+
+
+# -------------------------------------------------------------------------------
+#
+# Dark Room - a room with states
+#
+# This room limits the movemenets of its denizens unless they carry an active
+# LightSource object (LightSource is defined in
+# tutorialworld.objects.LightSource)
+#
+# -------------------------------------------------------------------------------
+
+
+DARK_MESSAGES=(
+ "It is pitch black. You are likely to be eaten by a grue.",
+ "It's pitch black. You fumble around but cannot find anything.",
+ "You don't see a thing. You feel around, managing to bump your fingers hard against something. Ouch!",
+ "You don't see a thing! Blindly grasping the air around you, you find nothing.",
+ "It's totally dark here. You almost stumble over some un-evenness in the ground.",
+ "You are completely blind. For a moment you think you hear someone breathing nearby ... "
+ "\n ... surely you must be mistaken.",
+ "Blind, you think you find some sort of object on the ground, but it turns out to be just a stone.",
+ "Blind, you bump into a wall. The wall seems to be covered with some sort of vegetation,"
+ " but its too damp to burn.",
+ "You can't see anything, but the air is damp. It feels like you are far underground.",
+)
+
+ALREADY_LIGHTSOURCE=(
+ "You don't want to stumble around in blindness anymore. You already "
+ "found what you need. Let's get light already!"
+)
+
+FOUND_LIGHTSOURCE=(
+ "Your fingers bump against a splinter of wood in a corner."
+ " It smells of resin and seems dry enough to burn! "
+ "You pick it up, holding it firmly. Now you just need to"
+ " |wlight|n it using the flint and steel you carry with you."
+)
+
+
+
[docs]classCmdLookDark(Command):
+ """
+ Look around in darkness
+
+ Usage:
+ look
+
+ Look around in the darkness, trying
+ to find something.
+ """
+
+ key="look"
+ aliases=["l","feel","search","feel around","fiddle"]
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """
+ Implement the command.
+
+ This works both as a look and a search command; there is a
+ random chance of eventually finding a light source.
+ """
+ caller=self.caller
+
+ # count how many searches we've done
+ nr_searches=caller.ndb.dark_searches
+ ifnr_searchesisNone:
+ nr_searches=0
+ caller.ndb.dark_searches=nr_searches
+
+ ifnr_searches<4andrandom.random()<0.90:
+ # we don't find anything
+ caller.msg(random.choice(DARK_MESSAGES))
+ caller.ndb.dark_searches+=1
+ else:
+ # we could have found something!
+ ifany(objforobjincaller.contentsifutils.inherits_from(obj,LightSource)):
+ # we already carry a LightSource object.
+ caller.msg(ALREADY_LIGHTSOURCE)
+ else:
+ # don't have a light source, create a new one.
+ create_object(LightSource,key="splinter",location=caller)
+ caller.msg(FOUND_LIGHTSOURCE)
+
+
+
[docs]classCmdDarkHelp(Command):
+ """
+ Help command for the dark state.
+ """
+
+ key="help"
+ locks="cmd:all()"
+ help_category="TutorialWorld"
+
+
[docs]deffunc(self):
+ """
+ Replace the the help command with a not-so-useful help
+ """
+ string=(
+ "Can't help you until you find some light! Try looking/feeling around for something to burn. "
+ "You shouldn't give up even if you don't find anything right away."
+ )
+ self.caller.msg(string)
+
+
+
[docs]classCmdDarkNoMatch(Command):
+ """
+ This is a system command. Commands with special keys are used to
+ override special sitations in the game. The CMD_NOMATCH is used
+ when the given command is not found in the current command set (it
+ replaces Evennia's default behavior or offering command
+ suggestions)
+ """
+
+ key=syscmdkeys.CMD_NOMATCH
+ locks="cmd:all()"
+
+
[docs]deffunc(self):
+ """Implements the command."""
+ self.caller.msg(
+ "Until you find some light, there's not much you can do. "
+ "Try feeling around, maybe you'll find something helpful!"
+ )
+
+
+
[docs]classDarkCmdSet(CmdSet):
+ """
+ Groups the commands of the dark room together. We also import the
+ default say command here so that players can still talk in the
+ darkness.
+
+ We give the cmdset the mergetype "Replace" to make sure it
+ completely replaces whichever command set it is merged onto
+ (usually the default cmdset)
+ """
+
+ key="darkroom_cmdset"
+ mergetype="Replace"
+ priority=2
+
+
[docs]classDarkRoom(TutorialRoom):
+ """
+ A dark room. This tries to start the DarkState script on all
+ objects entering. The script is responsible for making sure it is
+ valid (that is, that there is no light source shining in the room).
+
+ The is_lit Attribute is used to define if the room is currently lit
+ or not, so as to properly echo state changes.
+
+ Since this room (in the tutorial) is meant as a sort of catch-all,
+ we also make sure to heal characters ending up here, since they
+ may have been beaten up by the ghostly apparition at this point.
+
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called when object is first created.
+ """
+ super().at_object_creation()
+ self.db.tutorial_info="This is a room with custom command sets on itself."
+ # the room starts dark.
+ self.db.is_lit=False
+ self.cmdset.add(DarkCmdSet,permanent=True)
+
+
[docs]defat_init(self):
+ """
+ Called when room is first recached (such as after a reload)
+ """
+ self.check_light_state()
+
+ def_carries_light(self,obj):
+ """
+ Checks if the given object carries anything that gives light.
+
+ Note that we do NOT look for a specific LightSource typeclass,
+ but for the Attribute is_giving_light - this makes it easy to
+ later add other types of light-giving items. We also accept
+ if there is a light-giving object in the room overall (like if
+ a splinter was dropped in the room)
+ """
+ return(
+ obj.is_superuser
+ orobj.db.is_giving_light
+ orany(oforoinobj.contentsifo.db.is_giving_light)
+ )
+
+ def_heal(self,character):
+ """
+ Heal a character.
+ """
+ health=character.db.health_maxor20
+ character.db.health=health
+
+
[docs]defcheck_light_state(self,exclude=None):
+ """
+ This method checks if there are any light sources in the room.
+ If there isn't it makes sure to add the dark cmdset to all
+ characters in the room. It is called whenever characters enter
+ the room and also by the Light sources when they turn on.
+
+ Args:
+ exclude (Object): An object to not include in the light check.
+ """
+ ifany(self._carries_light(obj)forobjinself.contentsifobj!=exclude):
+ self.locks.add("view:all()")
+ self.cmdset.remove(DarkCmdSet)
+ self.db.is_lit=True
+ forcharin(objforobjinself.contentsifobj.has_account):
+ # this won't do anything if it is already removed
+ char.msg("The room is lit up.")
+ else:
+ # noone is carrying light - darken the room
+ self.db.is_lit=False
+ self.locks.add("view:false()")
+ self.cmdset.add(DarkCmdSet,permanent=True)
+ forcharin(objforobjinself.contentsifobj.has_account):
+ ifchar.is_superuser:
+ char.msg("You are Superuser, so you are not affected by the dark state.")
+ else:
+ # put players in darkness
+ char.msg("The room is completely dark.")
+
+
[docs]defat_object_receive(self,obj,source_location):
+ """
+ Called when an object enters the room.
+ """
+ ifobj.has_account:
+ # a puppeted object, that is, a Character
+ self._heal(obj)
+ # in case the new guy carries light with them
+ self.check_light_state()
+
+
[docs]defat_object_leave(self,obj,target_location):
+ """
+ In case people leave with the light, we make sure to clear the
+ DarkCmdSet if necessary. This also works if they are
+ teleported away.
+ """
+ # since this hook is called while the object is still in the room,
+ # we exclude it from the light check, to ignore any light sources
+ # it may be carrying.
+ self.check_light_state(exclude=obj)
+
+
+# -------------------------------------------------------------
+#
+# Teleport room - puzzles solution
+#
+# This is a sort of puzzle room that requires a certain
+# attribute on the entering character to be the same as
+# an attribute of the room. If not, the character will
+# be teleported away to a target location. This is used
+# by the Obelisk - grave chamber puzzle, where one must
+# have looked at the obelisk to get an attribute set on
+# oneself, and then pick the grave chamber with the
+# matching imagery for this attribute.
+#
+# -------------------------------------------------------------
+
+
+
[docs]classTeleportRoom(TutorialRoom):
+ """
+ Teleporter - puzzle room.
+
+ Important attributes (set at creation):
+ puzzle_key - which attr to look for on character
+ puzzle_value - what char.db.puzzle_key must be set to
+ success_teleport_to - where to teleport in case if success
+ success_teleport_msg - message to echo while teleporting to success
+ failure_teleport_to - where to teleport to in case of failure
+ failure_teleport_msg - message to echo while teleporting to failure
+
+ """
+
+
[docs]defat_object_creation(self):
+ """Called at first creation"""
+ super().at_object_creation()
+ # what character.db.puzzle_clue must be set to, to avoid teleportation.
+ self.db.puzzle_value=1
+ # target of successful teleportation. Can be a dbref or a
+ # unique room name.
+ self.db.success_teleport_msg="You are successful!"
+ self.db.success_teleport_to="treasure room"
+ # the target of the failure teleportation.
+ self.db.failure_teleport_msg="You fail!"
+ self.db.failure_teleport_to="dark cell"
+
+
[docs]defat_object_receive(self,character,source_location):
+ """
+ This hook is called by the engine whenever the player is moved into
+ this room.
+ """
+ ifnotcharacter.has_account:
+ # only act on player characters.
+ return
+ # determine if the puzzle is a success or not
+ is_success=str(character.db.puzzle_clue)==str(self.db.puzzle_value)
+ teleport_to=self.db.success_teleport_toifis_successelseself.db.failure_teleport_to
+ # note that this returns a list
+ results=search_object(teleport_to)
+ ifnotresultsorlen(results)>1:
+ # we cannot move anywhere since no valid target was found.
+ character.msg("no valid teleport target for %s was found."%teleport_to)
+ return
+ ifcharacter.is_superuser:
+ # superusers don't get teleported
+ character.msg("Superuser block: You would have been teleported to %s."%results[0])
+ return
+ # perform the teleport
+ ifis_success:
+ character.msg(self.db.success_teleport_msg)
+ else:
+ character.msg(self.db.failure_teleport_msg)
+ # teleport quietly to the new place
+ character.move_to(results[0],quiet=True,move_hooks=False)
+ # we have to call this manually since we turn off move_hooks
+ # - this is necessary to make the target dark room aware of an
+ # already carried light.
+ results[0].at_object_receive(character,self)
+
+
+# -------------------------------------------------------------
+#
+# Outro room - unique exit room
+#
+# Cleans up the character from all tutorial-related properties.
+#
+# -------------------------------------------------------------
+
+
+
[docs]classOutroRoom(TutorialRoom):
+ """
+ Outro room.
+
+ Called when exiting the tutorial, cleans the
+ character of tutorial-related attributes.
+
+ """
+
+
[docs]defat_object_creation(self):
+ """
+ Called when the room is first created.
+ """
+ super().at_object_creation()
+ self.db.tutorial_info=(
+ "The last room of the tutorial. "
+ "This cleans up all temporary Attributes "
+ "the tutorial may have assigned to the "
+ "character."
+ )
+"""
+Unix-like Command style parent
+
+Evennia contribution, Vincent Le Geoff 2017
+
+This module contains a command class that allows for unix-style command syntax in-game, using
+--options, positional arguments and stuff like -n 10 etc similarly to a unix command. It might not
+the best syntax for the average player but can be really useful for builders when they need to have
+a single command do many things with many options. It uses the ArgumentParser from Python's standard
+library under the hood.
+
+To use, inherit `UnixCommand` from this module from your own commands. You need
+to override two methods:
+
+- The `init_parser` method, which adds options to the parser. Note that you should normally
+ *not* override the normal `parse` method when inheriting from `UnixCommand`.
+- The `func` method, called to execute the command once parsed (like any Command).
+
+Here's a short example:
+
+```python
+class CmdPlant(UnixCommand):
+
+ '''
+ Plant a tree or plant.
+
+ This command is used to plant something in the room you are in.
+
+ Examples:
+ plant orange -a 8
+ plant strawberry --hidden
+ plant potato --hidden --age 5
+
+ '''
+
+ key = "plant"
+
+ def init_parser(self):
+ "Add the arguments to the parser."
+ # 'self.parser' inherits `argparse.ArgumentParser`
+ self.parser.add_argument("key",
+ help="the key of the plant to be planted here")
+ self.parser.add_argument("-a", "--age", type=int,
+ default=1, help="the age of the plant to be planted")
+ self.parser.add_argument("--hidden", action="store_true",
+ help="should the newly-planted plant be hidden to players?")
+
+ def func(self):
+ "func is called only if the parser succeeded."
+ # 'self.opts' contains the parsed options
+ key = self.opts.key
+ age = self.opts.age
+ hidden = self.opts.hidden
+ self.msg("Going to plant '{}', age={}, hidden={}.".format(
+ key, age, hidden))
+```
+
+To see the full power of argparse and the types of supported options, visit
+[the documentation of argparse](https://docs.python.org/2/library/argparse.html).
+
+"""
+
+importargparse
+importshlex
+fromtextwrapimportdedent
+
+fromevenniaimportCommand,InterruptCommand
+fromevennia.utils.ansiimportraw
+
+
+
[docs]classUnixCommandParser(argparse.ArgumentParser):
+
+ """A modifier command parser for unix commands.
+
+ This parser is used to replace `argparse.ArgumentParser`. It
+ is aware of the command calling it, and can more easily report to
+ the caller. Some features (like the "brutal exit" of the original
+ parser) are disabled or replaced. This parser is used by UnixCommand
+ and creating one directly isn't recommended nor necessary. Even
+ adding a sub-command will use this replaced parser automatically.
+
+ """
+
+
[docs]def__init__(self,prog,description="",epilog="",command=None,**kwargs):
+ """
+ Build a UnixCommandParser with a link to the command using it.
+
+ Args:
+ prog (str): the program name (usually the command key).
+ description (str): a very brief line to show in the usage text.
+ epilog (str): the epilog to show below options.
+ command (Command): the command calling the parser.
+
+ Keyword Args:
+ Additional keyword arguments are directly sent to
+ `argparse.ArgumentParser`. You will find them on the
+ [parser's documentation](https://docs.python.org/2/library/argparse.html).
+
+ Note:
+ It's doubtful you would need to create this parser manually.
+ The `UnixCommand` does that automatically. If you create
+ sub-commands, this class will be used.
+
+ """
+ prog=progorcommand.key
+ super().__init__(
+ prog=prog,description=description,conflict_handler="resolve",add_help=False,**kwargs
+ )
+ self.command=command
+ self.post_help=epilog
+
+ defn_exit(code=None,msg=None):
+ raiseParseError(msg)
+
+ self.exit=n_exit
+
+ # Replace the -h/--help
+ self.add_argument(
+ "-h","--hel",nargs=0,action=HelpAction,help="display the command help"
+ )
+
+
[docs]defformat_usage(self):
+ """Return the usage line.
+
+ Note:
+ This method is present to return the raw-escaped usage line,
+ in order to avoid unintentional color codes.
+
+ """
+ returnraw(super().format_usage())
+
+
[docs]defformat_help(self):
+ """Return the parser help, including its epilog.
+
+ Note:
+ This method is present to return the raw-escaped help,
+ in order to avoid unintentional color codes. Color codes
+ in the epilog (the command docstring) are supported.
+
+ """
+ autohelp=raw(super().format_help())
+ return"\n"+autohelp+"\n"+self.post_help
+
+
[docs]defprint_usage(self,file=None):
+ """Print the usage to the caller.
+
+ Args:
+ file (file-object): not used here, the caller is used.
+
+ Note:
+ This method will override `argparse.ArgumentParser`'s in order
+ to not display the help on stdout or stderr, but to the
+ command's caller.
+
+ """
+ ifself.command:
+ self.command.msg(self.format_usage().strip())
+
+
[docs]defprint_help(self,file=None):
+ """Print the help to the caller.
+
+ Args:
+ file (file-object): not used here, the caller is used.
+
+ Note:
+ This method will override `argparse.ArgumentParser`'s in order
+ to not display the help on stdout or stderr, but to the
+ command's caller.
+
+ """
+ ifself.command:
+ self.command.msg(self.format_help().strip())
+
+
+
[docs]classHelpAction(argparse.Action):
+
+ """Override the -h/--help action in the default parser.
+
+ Using the default -h/--help will call the exit function in different
+ ways, preventing the entire help message to be provided. Hence
+ this override.
+
+ """
+
+ def__call__(self,parser,namespace,values,option_string=None):
+ """If asked for help, display to the caller."""
+ ifparser.command:
+ parser.command.msg(parser.format_help().strip())
+ parser.exit(0,"")
+
+
+
[docs]classUnixCommand(Command):
+ """
+ Unix-type commands, supporting short and long options.
+
+ This command syntax uses the Unix-style commands with short options
+ (-X) and long options (--something). The `argparse` module is
+ used to parse the command.
+
+ In order to use it, you should override two methods:
+ - `init_parser`: this method is called when the command is created.
+ It can be used to set options in the parser. `self.parser`
+ contains the `argparse.ArgumentParser`, so you can add arguments
+ here.
+ - `func`: this method is called to execute the command, but after
+ the parser has checked the arguments given to it are valid.
+ You can access the namespace of valid arguments in `self.opts`
+ at this point.
+
+ The help of UnixCommands is derived from the docstring, in a
+ slightly different way than usual: the first line of the docstring
+ is used to represent the program description (the very short
+ line at the top of the help message). The other lines below are
+ used as the program's "epilog", displayed below the options. It
+ means in your docstring, you don't have to write the options.
+ They will be automatically provided by the parser and displayed
+ accordingly. The `argparse` module provides a default '-h' or
+ '--help' option on the command. Typing |whelp commandname|n will
+ display the same as |wcommandname -h|n, though this behavior can
+ be changed.
+
+ """
+
+
[docs]def__init__(self,**kwargs):
+ """
+ The lockhandler works the same as for objects.
+ optional kwargs will be set as properties on the Command at runtime,
+ overloading evential same-named class properties.
+
+ """
+ super().__init__(**kwargs)
+
+ # Create the empty UnixCommandParser, inheriting argparse.ArgumentParser
+ lines=dedent(self.__doc__.strip("\n")).splitlines()
+ description=lines[0].strip()
+ epilog="\n".join(lines[1:]).strip()
+ self.parser=UnixCommandParser(None,description,epilog,command=self)
+
+ # Fill the argument parser
+ self.init_parser()
+
+
[docs]definit_parser(self):
+ """
+ Configure the argument parser, adding in options.
+
+ Note:
+ This method is to be overridden in order to add options
+ to the argument parser. Use `self.parser`, which contains
+ the `argparse.ArgumentParser`. You can, for instance,
+ use its `add_argument` method.
+
+ """
+ pass
+
+
[docs]deffunc(self):
+ """Override to handle the command execution."""
+ pass
+
+
[docs]defget_help(self,caller,cmdset):
+ """
+ Return the help message for this command and this caller.
+
+ Args:
+ caller (Object or Player): the caller asking for help on the command.
+ cmdset (CmdSet): the command set (if you need additional commands).
+
+ Returns:
+ docstring (str): the help text to provide the caller for this command.
+
+ """
+ returnself.parser.format_help()
+
+
[docs]defparse(self):
+ """
+ Process arguments provided in `self.args`.
+
+ Note:
+ You should not override this method. Consider overriding
+ `init_parser` instead.
+
+ """
+ try:
+ self.opts=self.parser.parse_args(shlex.split(self.args))
+ exceptParseErroraserr:
+ msg=str(err)
+ ifmsg:
+ self.msg(msg)
+ raiseInterruptCommand
+"""
+Wilderness system
+
+Evennia contrib - titeuf87 2017
+
+This contrib provides a wilderness map. This is an area that can be huge where
+the rooms are mostly similar, except for some small cosmetic changes like the
+room name.
+
+Usage:
+
+ This contrib does not provide any commands. Instead the @py command can be
+ used.
+
+ A wilderness map needs to created first. There can be different maps, all
+ with their own name. If no name is provided, then a default one is used. Internally,
+ the wilderness is stored as a Script with the name you specify. If you don't
+ specify the name, a script named "default" will be created and used.
+
+ @py from evennia.contrib import wilderness; wilderness.create_wilderness()
+
+ Once created, it is possible to move into that wilderness map:
+
+ @py from evennia.contrib import wilderness; wilderness.enter_wilderness(me)
+
+ All coordinates used by the wilderness map are in the format of `(x, y)`
+ tuples. x goes from left to right and y goes from bottom to top. So `(0, 0)`
+ is the bottom left corner of the map.
+
+
+Customisation:
+
+ The defaults, while useable, are meant to be customised. When creating a
+ new wilderness map it is possible to give a "map provider": this is a
+ python object that is smart enough to create the map.
+
+ The default provider, WildernessMapProvider, just creates a grid area that
+ is unlimited in size.
+ This WildernessMapProvider can be subclassed to create more interesting
+ maps and also to customize the room/exit typeclass used.
+
+ There is also no command that allows players to enter the wilderness. This
+ still needs to be added: it can be a command or an exit, depending on your
+ needs.
+
+Customisation example:
+
+ To give an example of how to customize, we will create a very simple (and
+ small) wilderness map that is shaped like a pyramid. The map will be
+ provided as a string: a "." symbol is a location we can walk on.
+
+ Let's create a file world/pyramid.py:
+
+ ```python
+ map_str = \"\"\"
+ .
+ ...
+ .....
+ .......
+ \"\"\"
+
+ from evennia.contrib import wilderness
+
+ class PyramidMapProvider(wilderness.WildernessMapProvider):
+
+ def is_valid_coordinates(self, wilderness, coordinates):
+ "Validates if these coordinates are inside the map"
+ x, y = coordinates
+ try:
+ lines = map_str.split("\n")
+ # The reverse is needed because otherwise the pyramid will be
+ # upside down
+ lines.reverse()
+ line = lines[y]
+ column = line[x]
+ return column == "."
+ except IndexError:
+ return False
+
+ def get_location_name(self, coordinates):
+ "Set the location name"
+ x, y = coordinates
+ if y == 3:
+ return "Atop the pyramid."
+ else:
+ return "Inside a pyramid."
+
+ def at_prepare_room(self, coordinates, caller, room):
+ "Any other changes done to the room before showing it"
+ x, y = coordinates
+ desc = "This is a room in the pyramid."
+ if y == 3 :
+ desc = "You can see far and wide from the top of the pyramid."
+ room.db.desc = desc
+ ```
+
+ Now we can use our new pyramid-shaped wilderness map. From inside Evennia we
+ create a new wilderness (with the name "default") but using our new map provider:
+
+ ```
+ @py from world import pyramid as p; p.wilderness.create_wilderness(mapprovider=p.PyramidMapProvider())
+
+ @py from evennia.contrib import wilderness; wilderness.enter_wilderness(me, coordinates=(4, 1))
+
+ ```
+Implementation details:
+
+ When a character moves into the wilderness, they get their own room. If
+ they move, instead of moving the character, the room changes to match the
+ new coordinates.
+ If a character meets another character in the wilderness, then their room
+ merges. When one of the character leaves again, they each get their own
+ separate rooms.
+ Rooms are created as needed. Unneeded rooms are stored away to avoid the
+ overhead cost of creating new rooms again in the future.
+
+"""
+
+fromevenniaimportDefaultRoom,DefaultExit,DefaultScript
+fromevenniaimportcreate_object,create_script
+fromevennia.utilsimportinherits_from
+
+
+
[docs]defcreate_wilderness(name="default",mapprovider=None):
+ """
+ Creates a new wilderness map. Does nothing if a wilderness map already
+ exists with the same name.
+
+ Args:
+ name (str, optional): the name to use for that wilderness map
+ mapprovider (WildernessMap instance, optional): an instance of a
+ WildernessMap class (or subclass) that will be used to provide the
+ layout of this wilderness map. If none is provided, the default
+ infinite grid map will be used.
+
+ """
+ ifWildernessScript.objects.filter(db_key=name).exists():
+ # Don't create two wildernesses with the same name
+ return
+
+ ifnotmapprovider:
+ mapprovider=WildernessMapProvider()
+ script=create_script(WildernessScript,key=name)
+ script.db.mapprovider=mapprovider
+
+
+
[docs]defenter_wilderness(obj,coordinates=(0,0),name="default"):
+ """
+ Moves obj into the wilderness. The wilderness needs to exist first and the
+ provided coordinates needs to be valid inside that wilderness.
+
+ Args:
+ obj (object): the object to move into the wilderness
+ coordinates (tuple), optional): the coordinates to move obj to into
+ the wilderness. If not provided, defaults (0, 0)
+ name (str, optional): name of the wilderness map, if not using the
+ default one
+
+ Returns:
+ bool: True if obj successfully moved into the wilderness.
+ """
+ ifnotWildernessScript.objects.filter(db_key=name).exists():
+ returnFalse
+
+ script=WildernessScript.objects.get(db_key=name)
+ ifscript.is_valid_coordinates(coordinates):
+ script.move_obj(obj,coordinates)
+ returnTrue
+ else:
+ returnFalse
+
+
+
[docs]defget_new_coordinates(coordinates,direction):
+ """
+ Returns the coordinates of direction applied to the provided coordinates.
+
+ Args:
+ coordinates: tuple of (x, y)
+ direction: a direction string (like "northeast")
+
+ Returns:
+ tuple: tuple of (x, y) coordinates
+ """
+ x,y=coordinates
+
+ ifdirectionin("north","northwest","northeast"):
+ y+=1
+ ifdirectionin("south","southwest","southeast"):
+ y-=1
+ ifdirectionin("northwest","west","southwest"):
+ x-=1
+ ifdirectionin("northeast","east","southeast"):
+ x+=1
+
+ return(x,y)
+
+
+
[docs]classWildernessScript(DefaultScript):
+ """
+ This is the main "handler" for the wilderness system: inside here the
+ coordinates of every item currently inside the wilderness is stored. This
+ script is responsible for creating rooms as needed and storing rooms away
+ into storage when they are not needed anymore.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Only called once, when the script is created. This is a default Evennia
+ hook.
+ """
+ self.persistent=True
+
+ # Store the coordinates of every item that is inside the wilderness
+ # Key: object, Value: (x, y)
+ self.db.itemcoordinates={}
+
+ # Store the rooms that are used as views into the wilderness
+ # Key: (x, y), Value: room object
+ self.db.rooms={}
+
+ # Created rooms that are not needed anymore are stored there. This
+ # allows quick retrieval if a new room is needed without having to
+ # create it.
+ self.db.unused_rooms=[]
+
+ @property
+ defmapprovider(self):
+ """
+ Shortcut property to the map provider.
+
+ Returns:
+ MapProvider: the mapprovider used with this wilderness
+ """
+ returnself.db.mapprovider
+
+ @property
+ defitemcoordinates(self):
+ """
+ Returns a dictionary with the coordinates of every item inside this
+ wilderness map. The key is the item, the value are the coordinates as
+ (x, y) tuple.
+
+ Returns:
+ {item: coordinates}
+ """
+ returnself.db.itemcoordinates
+
+
[docs]defat_start(self):
+ """
+ Called when the script is started and also after server reloads.
+ """
+ forcoordinates,roominself.db.rooms.items():
+ room.ndb.wildernessscript=self
+ room.ndb.active_coordinates=coordinates
+ foriteminlist(self.db.itemcoordinates.keys()):
+ # Items deleted from the wilderness leave None type 'ghosts'
+ # that must be cleaned out
+ ifitemisNone:
+ delself.db.itemcoordinates[item]
+ continue
+ item.ndb.wilderness=self
+
+
[docs]defis_valid_coordinates(self,coordinates):
+ """
+ Returns True if coordinates are valid (and can be travelled to).
+ Otherwise returns False
+
+ Args:
+ coordinates (tuple): coordinates as (x, y) tuple
+
+ Returns:
+ bool: True if the coordinates are valid
+ """
+ returnself.mapprovider.is_valid_coordinates(self,coordinates)
+
+
[docs]defget_obj_coordinates(self,obj):
+ """
+ Returns the coordinates of obj in the wilderness.
+
+ Returns (x, y)
+
+ Args:
+ obj (object): an object inside the wilderness
+
+ Returns:
+ tuple: (x, y) tuple of where obj is located
+ """
+ returnself.itemcoordinates[obj]
+
+
[docs]defget_objs_at_coordinates(self,coordinates):
+ """
+ Returns a list of every object at certain coordinates.
+
+ Imeplementation detail: this uses a naive iteration through every
+ object inside the wilderness which could cause slow downs when there
+ are a lot of objects in the map.
+
+ Args:
+ coordinates (tuple): a coordinate tuple like (x, y)
+
+ Returns:
+ [Object, ]: list of Objects at coordinates
+ """
+ result=[]
+ foritem,item_coordinatesinlist(self.itemcoordinates.items()):
+ # Items deleted from the wilderness leave None type 'ghosts'
+ # that must be cleaned out
+ ifitemisNone:
+ delself.db.itemcoordinates[item]
+ continue
+ ifcoordinates==item_coordinates:
+ result.append(item)
+ returnresult
+
+
[docs]defmove_obj(self,obj,new_coordinates):
+ """
+ Moves obj to new coordinates in this wilderness.
+
+ Args:
+ obj (object): the object to move
+ new_coordinates (tuple): tuple of (x, y) where to move obj to.
+ """
+ # Update the position of this obj in the wilderness
+ self.itemcoordinates[obj]=new_coordinates
+ old_room=obj.location
+
+ # Remove the obj's location. This is needed so that the object does not
+ # appear in its old room should that room be deleted.
+ obj.location=None
+
+ try:
+ # See if we already have a room for that location
+ room=self.db.rooms[new_coordinates]
+ # There is. Try to destroy the old_room if it is not needed anymore
+ self._destroy_room(old_room)
+ exceptKeyError:
+ # There is no room yet at new_location
+ if(old_roomandnotinherits_from(old_room,WildernessRoom))or(notold_room):
+ # Obj doesn't originally come from a wilderness room.
+ # We'll create a new one then.
+ room=self._create_room(new_coordinates,obj)
+ else:
+ # Obj does come from another wilderness room
+ create_new_room=False
+
+ ifold_room.wilderness!=self:
+ # ... but that other wilderness room belongs to another
+ # wilderness map
+ create_new_room=True
+ old_room.wilderness.at_after_object_leave(obj)
+ else:
+ foriteminold_room.contents:
+ ifitem.has_account:
+ # There is still a player in the old room.
+ # Let's create a new room and not touch that old
+ # room.
+ create_new_room=True
+ break
+
+ ifcreate_new_room:
+ # Create a new room to hold obj, not touching any obj's in
+ # the old room
+ room=self._create_room(new_coordinates,obj)
+ else:
+ # The old_room is empty: we are just going to reuse that
+ # room instead of creating a new one
+ room=old_room
+
+ room.set_active_coordinates(new_coordinates,obj)
+ obj.location=room
+ obj.ndb.wilderness=self
+
+ def_create_room(self,coordinates,report_to):
+ """
+ Gets a new WildernessRoom to be used for the provided coordinates.
+
+ It first tries to retrieve a room out of storage. If there are no rooms
+ left a new one will be created.
+
+ Args:
+ coordinates (tuple): coordinate tuple of (x, y)
+ report_to (object): the obj to return error messages to
+ """
+ ifself.db.unused_rooms:
+ # There is still unused rooms stored in storage, let's get one of
+ # those
+ room=self.db.unused_rooms.pop()
+ else:
+ # No more unused rooms...time to make a new one.
+
+ # First, create the room
+ room=create_object(
+ typeclass=self.mapprovider.room_typeclass,key="Wilderness",report_to=report_to
+ )
+
+ # Then the exits
+ exits=[
+ ("north","n"),
+ ("northeast","ne"),
+ ("east","e"),
+ ("southeast","se"),
+ ("south","s"),
+ ("southwest","sw"),
+ ("west","w"),
+ ("northwest","nw"),
+ ]
+ forkey,aliasinexits:
+ create_object(
+ typeclass=self.mapprovider.exit_typeclass,
+ key=key,
+ aliases=[alias],
+ location=room,
+ destination=room,
+ report_to=report_to,
+ )
+
+ room.ndb.active_coordinates=coordinates
+ room.ndb.wildernessscript=self
+ self.db.rooms[coordinates]=room
+
+ returnroom
+
+ def_destroy_room(self,room):
+ """
+ Moves a room back to storage. If room is not a WildernessRoom or there
+ is a player inside the room, then this does nothing.
+
+ Args:
+ room (WildernessRoom): the room to put in storage
+ """
+ ifnotroomornotinherits_from(room,WildernessRoom):
+ return
+
+ foriteminroom.contents:
+ ifitem.has_account:
+ # There is still a character in that room. We can't get rid of
+ # it just yet
+ break
+ else:
+ # No characters left in the room.
+
+ # Clear the location of every obj in that room first
+ foriteminroom.contents:
+ ifitem.destinationanditem.destination==room:
+ # Ignore the exits, they stay in the room
+ continue
+ item.location=None
+
+ # Then delete its reference
+ delself.db.rooms[room.ndb.active_coordinates]
+ # And finally put this room away in storage
+ self.db.unused_rooms.append(room)
+
+
[docs]defat_after_object_leave(self,obj):
+ """
+ Called after an object left this wilderness map. Used for cleaning up.
+
+ Args:
+ obj (object): the object that left
+ """
+ # Remove that obj from the wilderness's coordinates dict
+ loc=self.db.itemcoordinates[obj]
+ delself.db.itemcoordinates[obj]
+
+ # And see if we can put that room away into storage.
+ room=self.db.rooms[loc]
+ self._destroy_room(room)
+
+
+
[docs]classWildernessRoom(DefaultRoom):
+ """
+ This is a single room inside the wilderness. This room provides a "view"
+ into the wilderness map. When an account moves around, instead of going to
+ another room as with traditional rooms, they stay in the same room but the
+ room itself changes to display another area of the wilderness.
+ """
+
+ @property
+ defwilderness(self):
+ """
+ Shortcut property to the wilderness script this room belongs to.
+
+ Returns:
+ WildernessScript: the WildernessScript attached to this room
+ """
+ returnself.ndb.wildernessscript
+
+ @property
+ deflocation_name(self):
+ """
+ Returns the name of the wilderness at this room's coordinates.
+
+ Returns:
+ name (str)
+ """
+ returnself.wilderness.mapprovider.get_location_name(self.coordinates)
+
+ @property
+ defcoordinates(self):
+ """
+ Returns the coordinates of this room into the wilderness.
+
+ Returns:
+ tuple: (x, y) coordinates of where this room is inside the
+ wilderness.
+ """
+ returnself.ndb.active_coordinates
+
+
[docs]defat_object_receive(self,moved_obj,source_location):
+ """
+ Called after an object has been moved into this object. This is a
+ default Evennia hook.
+
+ Args:
+ moved_obj (Object): The object moved into this one.
+ source_location (Object): Where `moved_obj` came from.
+ """
+ ifisinstance(moved_obj,WildernessExit):
+ # Ignore exits looping back to themselves: those are the regular
+ # n, ne, ... exits.
+ return
+
+ itemcoords=self.wilderness.db.itemcoordinates
+ ifmoved_objinitemcoords:
+ # This object was already in the wilderness. We need to make sure
+ # it goes to the correct room it belongs to.
+ # Otherwise the following issue can come up:
+ # 1) Player 1 and Player 2 share a room
+ # 2) Player 1 disconnects
+ # 3) Player 2 moves around
+ # 4) Player 1 reconnects
+ # Player 1 will end up in player 2's room, which has the wrong
+ # coordinates
+
+ coordinates=itemcoords[moved_obj]
+ # Setting the location to None is important here so that we always
+ # get a "fresh" room
+ moved_obj.location=None
+ self.wilderness.move_obj(moved_obj,coordinates)
+ else:
+ # This object wasn't in the wilderness yet. Let's add it.
+ itemcoords[moved_obj]=self.coordinates
+
+
[docs]defat_object_leave(self,moved_obj,target_location):
+ """
+ Called just before an object leaves from inside this object. This is a
+ default Evennia hook.
+
+ Args:
+ moved_obj (Object): The object leaving
+ target_location (Object): Where `moved_obj` is going.
+
+ """
+ self.wilderness.at_after_object_leave(moved_obj)
+
+
[docs]defset_active_coordinates(self,new_coordinates,obj):
+ """
+ Changes this room to show the wilderness map from other coordinates.
+
+ Args:
+ new_coordinates (tuple): coordinates as tuple of (x, y)
+ obj (Object): the object that moved into this room and caused the
+ coordinates to change
+ """
+ # Remove the reference for the old coordinates...
+ rooms=self.wilderness.db.rooms
+ delrooms[self.coordinates]
+ # ...and add it for the new coordinates.
+ self.ndb.active_coordinates=new_coordinates
+ rooms[self.coordinates]=self
+
+ # Every obj inside this room will get its location set to None
+ foriteminself.contents:
+ ifnotitem.destinationoritem.destination!=item.location:
+ item.location=None
+ # And every obj matching the new coordinates will get its location set
+ # to this room
+ foriteminself.wilderness.get_objs_at_coordinates(new_coordinates):
+ item.location=self
+
+ # Fix the lockfuncs for the exit so we can't go where we're not
+ # supposed to go
+ forexitinself.exits:
+ ifexit.destination!=self:
+ continue
+ x,y=get_new_coordinates(new_coordinates,exit.key)
+ valid=self.wilderness.is_valid_coordinates((x,y))
+
+ ifvalid:
+ exit.locks.add("traverse:true();view:true()")
+ else:
+ exit.locks.add("traverse:false();view:false()")
+
+ # Finally call the at_prepare_room hook to give a chance to further
+ # customise it
+ self.wilderness.mapprovider.at_prepare_room(new_coordinates,obj,self)
+
+
[docs]defget_display_name(self,looker,**kwargs):
+ """
+ Displays the name of the object in a viewer-aware manner.
+
+ Args:
+ looker (TypedObject): The object or account that is looking
+ at/getting inforamtion for this object.
+
+ Returns:
+ name (str): A string containing the name of the object,
+ including the DBREF if this user is privileged to control
+ said object and also its coordinates into the wilderness map.
+
+ Notes:
+ This function could be extended to change how object names
+ appear to users in character, but be wary. This function
+ does not change an object's keys or aliases when
+ searching, and is expected to produce something useful for
+ builders.
+ """
+ ifself.locks.check_lockstring(looker,"perm(Builder)"):
+ name="{}(#{})".format(self.location_name,self.id)
+ else:
+ name=self.location_name
+
+ name+=" {0}".format(self.coordinates)
+ returnname
+
+
+
[docs]classWildernessExit(DefaultExit):
+ """
+ This is an Exit object used inside a WildernessRoom. Instead of changing
+ the location of an Object traversing through it (like a traditional exit
+ would do) it changes the coordinates of that traversing Object inside
+ the wilderness map.
+ """
+
+ @property
+ defwilderness(self):
+ """
+ Shortcut property to the wilderness script.
+
+ Returns:
+ WildernessScript: the WildernessScript attached to this exit's room
+ """
+ returnself.location.wilderness
+
+ @property
+ defmapprovider(self):
+ """
+ Shortcut property to the map provider.
+
+ Returns:
+ MapProvider object: the mapprovider object used with this
+ wilderness map.
+ """
+ returnself.wilderness.mapprovider
+
+
[docs]defat_traverse_coordinates(self,traversing_object,current_coordinates,new_coordinates):
+ """
+ Called when an object wants to travel from one place inside the
+ wilderness to another place inside the wilderness.
+
+ If this returns True, then the traversing can happen. Otherwise it will
+ be blocked.
+
+ This method is similar how the `at_traverse` works on normal exits.
+
+ Args:
+ traversing_object (Object): The object doing the travelling.
+ current_coordinates (tuple): (x, y) coordinates where
+ `traversing_object` currently is.
+ new_coordinates (tuple): (x, y) coordinates of where
+ `traversing_object` wants to travel to.
+
+ Returns:
+ bool: True if traversing_object is allowed to traverse
+ """
+ returnTrue
+
+
[docs]defat_traverse(self,traversing_object,target_location):
+ """
+ This implements the actual traversal. The traverse lock has
+ already been checked (in the Exit command) at this point.
+
+ Args:
+ traversing_object (Object): Object traversing us.
+ target_location (Object): Where target is going.
+
+ Returns:
+ bool: True if the traverse is allowed to happen
+
+ """
+ itemcoordinates=self.location.wilderness.db.itemcoordinates
+
+ current_coordinates=itemcoordinates[traversing_object]
+ new_coordinates=get_new_coordinates(current_coordinates,self.key)
+
+ ifnotself.at_traverse_coordinates(
+ traversing_object,current_coordinates,new_coordinates
+ ):
+ returnFalse
+
+ ifnottraversing_object.at_before_move(None):
+ returnFalse
+ traversing_object.location.msg_contents(
+ "{} leaves to {}".format(traversing_object.key,new_coordinates),
+ exclude=[traversing_object],
+ )
+
+ self.location.wilderness.move_obj(traversing_object,new_coordinates)
+
+ traversing_object.location.msg_contents(
+ "{} arrives from {}".format(traversing_object.key,current_coordinates),
+ exclude=[traversing_object],
+ )
+
+ traversing_object.at_after_move(None)
+ returnTrue
+
+
+
[docs]classWildernessMapProvider(object):
+ """
+ Default Wilderness Map provider.
+
+ This is a simple provider that just creates an infinite large grid area.
+ """
+
+ room_typeclass=WildernessRoom
+ exit_typeclass=WildernessExit
+
+
[docs]defis_valid_coordinates(self,wilderness,coordinates):
+ """Returns True if coordinates is valid and can be walked to.
+
+ Args:
+ wilderness: the wilderness script
+ coordinates (tuple): the coordinates to check as (x, y) tuple.
+
+ Returns:
+ bool: True if the coordinates are valid
+ """
+ x,y=coordinates
+ ifx<0:
+ returnFalse
+ ify<0:
+ returnFalse
+
+ returnTrue
+
+
[docs]defget_location_name(self,coordinates):
+ """
+ Returns a name for the position at coordinates.
+
+ Args:
+ coordinates (tuple): the coordinates as (x, y) tuple.
+
+ Returns:
+ name (str)
+ """
+ return"The wilderness"
+
+
[docs]defat_prepare_room(self,coordinates,caller,room):
+ """
+ Called when a room gets activated for certain coordinates. This happens
+ after every object is moved in it.
+ This can be used to set a custom room desc for instance or run other
+ customisations on the room.
+
+ Args:
+ coordinates (tuple): the coordinates as (x, y) where room is
+ located at
+ caller (Object): the object that moved into this room
+ room (WildernessRoom): the room object that will be used at that
+ wilderness location
+ Example:
+ An example use of this would to plug in a randomizer to show different
+ descriptions for different coordinates, or place a treasure at a special
+ coordinate.
+ """
+ pass
+"""
+This defines how to edit help entries in Admin.
+"""
+fromdjangoimportforms
+fromdjango.contribimportadmin
+fromevennia.help.modelsimportHelpEntry
+fromevennia.typeclasses.adminimportTagInline
+
+
+
[docs]defget_all_topics(self):
+ """
+ Get all topics.
+
+ Returns:
+ all (HelpEntries): All topics.
+
+ """
+ returnself.all()
+
+
[docs]defget_all_categories(self):
+ """
+ Return all defined category names with at least one topic in
+ them.
+
+ Returns:
+ matches (list): Unique list of category names across all
+ topics.
+
+ """
+ returnlist(set(topic.help_categoryfortopicinself.all()))
+
+
[docs]defall_to_category(self,default_category):
+ """
+ Shifts all help entries in database to default_category. This
+ action cannot be reverted. It is used primarily by the engine
+ when importing a default help database, making sure this ends
+ up in one easily separated category.
+
+ Args:
+ default_category (str): Category to move entries to.
+
+ """
+ topics=self.all()
+ fortopicintopics:
+ topic.help_category=default_category
+ topic.save()
+ string=_("Help database moved to category {default_category}").format(
+ default_category=default_category
+ )
+ logger.log_info(string)
+
+
[docs]defsearch_help(self,ostring,help_category=None):
+ """
+ Retrieve a search entry object.
+
+ Args:
+ ostring (str): The help topic to look for.
+ category (str): Limit the search to a particular help topic
+
+ """
+ ostring=ostring.strip().lower()
+ ifhelp_category:
+ returnself.filter(db_key__iexact=ostring,db_help_category__iexact=help_category)
+ else:
+ returnself.filter(db_key__iexact=ostring)
+"""
+Models for the help system.
+
+The database-tied help system is only half of Evennia's help
+functionality, the other one being the auto-generated command help
+that is created on the fly from each command's `__doc__` string. The
+persistent database system defined here is intended for all other
+forms of help that do not concern commands, like information about the
+game world, policy info, rules and similar.
+
+"""
+fromdjango.contrib.contenttypes.modelsimportContentType
+fromdjango.dbimportmodels
+fromdjango.urlsimportreverse
+fromdjango.utils.textimportslugify
+
+fromevennia.utils.idmapper.modelsimportSharedMemoryModel
+fromevennia.help.managerimportHelpEntryManager
+fromevennia.typeclasses.modelsimportTag,TagHandler,AliasHandler
+fromevennia.locks.lockhandlerimportLockHandler
+fromevennia.utils.utilsimportlazy_property
+
+__all__=("HelpEntry",)
+
+
+# ------------------------------------------------------------
+#
+# HelpEntry
+#
+# ------------------------------------------------------------
+
+
+
[docs]classHelpEntry(SharedMemoryModel):
+ """
+ A generic help entry.
+
+ An HelpEntry object has the following properties defined:
+ key - main name of entry
+ help_category - which category entry belongs to (defaults to General)
+ entrytext - the actual help text
+ permissions - perm strings
+
+ Method:
+ access
+
+ """
+
+ #
+ # HelpEntry Database Model setup
+ #
+ #
+ # These database fields are all set using their corresponding properties,
+ # named same as the field, but withtout the db_* prefix.
+
+ # title of the help entry
+ db_key=models.CharField(
+ "help key",max_length=255,unique=True,help_text="key to search for"
+ )
+ # help category
+ db_help_category=models.CharField(
+ "help category",
+ max_length=255,
+ default="General",
+ help_text="organizes help entries in lists",
+ )
+ # the actual help entry text, in any formatting.
+ db_entrytext=models.TextField(
+ "help entry",blank=True,help_text="the main body of help text"
+ )
+ # lock string storage
+ db_lock_storage=models.TextField("locks",blank=True,help_text="normally view:all().")
+ # tags are primarily used for permissions
+ db_tags=models.ManyToManyField(
+ Tag,
+ blank=True,
+ help_text="tags on this object. Tags are simple string markers to identify, group and alias objects.",
+ )
+ # (deprecated, only here to allow MUX helpfile load (don't use otherwise)).
+ # TODO: remove this when not needed anymore.
+ db_staff_only=models.BooleanField(default=False)
+
+ # Database manager
+ objects=HelpEntryManager()
+ _is_deleted=False
+
+ # lazy-loaded handlers
+
+
[docs]defaccess(self,accessing_obj,access_type="read",default=False):
+ """
+ Determines if another object has permission to access.
+ accessing_obj - object trying to access this one
+ access_type - type of access sought
+ default - what to return if no lock of access_type was found
+ """
+ returnself.locks.check(accessing_obj,access_type=access_type,default=default)
+
+ #
+ # Web/Django methods
+ #
+
+
[docs]defweb_get_admin_url(self):
+ """
+ Returns the URI path for the Django Admin page for this object.
+
+ ex. Account#1 = '/admin/accounts/accountdb/1/change/'
+
+ Returns:
+ path (str): URI path to Django Admin page for object.
+
+ """
+ content_type=ContentType.objects.get_for_model(self.__class__)
+ returnreverse(
+ "admin:%s_%s_change"%(content_type.app_label,content_type.model),args=(self.id,)
+ )
+
+
[docs]@classmethod
+ defweb_get_create_url(cls):
+ """
+ Returns the URI path for a View that allows users to create new
+ instances of this object.
+
+ ex. Chargen = '/characters/create/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'character-create' would be referenced by this method.
+
+ ex.
+ url(r'characters/create/', ChargenView.as_view(), name='character-create')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can create new objects is the
+ developer's responsibility.
+
+ Returns:
+ path (str): URI path to object creation page, if defined.
+
+ """
+ try:
+ returnreverse("%s-create"%slugify(cls._meta.verbose_name))
+ except:
+ return"#"
+
+
[docs]defweb_get_detail_url(self):
+ """
+ Returns the URI path for a View that allows users to view details for
+ this object.
+
+ ex. Oscar (Character) = '/characters/oscar/1/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'character-detail' would be referenced by this method.
+
+ ex.
+ url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$',
+ CharDetailView.as_view(), name='character-detail')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can view this object is the developer's
+ responsibility.
+
+ Returns:
+ path (str): URI path to object detail page, if defined.
+
+ """
+ try:
+ returnreverse(
+ "%s-detail"%slugify(self._meta.verbose_name),
+ kwargs={"category":slugify(self.db_help_category),"topic":slugify(self.db_key)},
+ )
+ exceptExceptionase:
+ print(e)
+ return"#"
+
+
[docs]defweb_get_update_url(self):
+ """
+ Returns the URI path for a View that allows users to update this
+ object.
+
+ ex. Oscar (Character) = '/characters/oscar/1/change/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'character-update' would be referenced by this method.
+
+ ex.
+ url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
+ CharUpdateView.as_view(), name='character-update')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can modify objects is the developer's
+ responsibility.
+
+ Returns:
+ path (str): URI path to object update page, if defined.
+
+ """
+ try:
+ returnreverse(
+ "%s-update"%slugify(self._meta.verbose_name),
+ kwargs={"category":slugify(self.db_help_category),"topic":slugify(self.db_key)},
+ )
+ except:
+ return"#"
+
+
[docs]defweb_get_delete_url(self):
+ """
+ Returns the URI path for a View that allows users to delete this object.
+
+ ex. Oscar (Character) = '/characters/oscar/1/delete/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'character-detail' would be referenced by this method.
+
+ ex.
+ url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
+ CharDeleteView.as_view(), name='character-delete')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can delete this object is the developer's
+ responsibility.
+
+ Returns:
+ path (str): URI path to object deletion page, if defined.
+
+ """
+ try:
+ returnreverse(
+ "%s-delete"%slugify(self._meta.verbose_name),
+ kwargs={"category":slugify(self.db_help_category),"topic":slugify(self.db_key)},
+ )
+ except:
+ return"#"
+
+ # Used by Django Sites/Admin
+ get_absolute_url=web_get_detail_url
+"""
+This module provides a set of permission lock functions for use
+with Evennia's permissions system.
+
+To call these locks, make sure this module is included in the
+settings tuple `PERMISSION_FUNC_MODULES` then define a lock on the form
+'<access_type>:func(args)' and add it to the object's lockhandler.
+Run the `access()` method of the handler to execute the lock check.
+
+Note that `accessing_obj` and `accessed_obj` can be any object type
+with a lock variable/field, so be careful to not expect
+a certain object type.
+
+
+**Appendix: MUX locks**
+
+Below is a list nicked from the MUX help file on the locks available
+in standard MUX. Most of these are not relevant to core Evennia since
+locks in Evennia are considerably more flexible and can be implemented
+on an individual command/typeclass basis rather than as globally
+available like the MUX ones. So many of these are not available in
+basic Evennia, but could all be implemented easily if needed for the
+individual game.
+
+```
+MUX Name: Affects: Effect:
+----------------------------------------------------------------------
+DefaultLock: Exits: controls who may traverse the exit to
+ its destination.
+ Evennia: "traverse:<lockfunc()>"
+ Rooms: controls whether the account sees the
+ SUCC or FAIL message for the room
+ following the room description when
+ looking at the room.
+ Evennia: Custom typeclass
+ Accounts/Things: controls who may GET the object.
+ Evennia: "get:<lockfunc()"
+ EnterLock: Accounts/Things: controls who may ENTER the object
+ Evennia:
+ GetFromLock: All but Exits: controls who may gets things from a
+ given location.
+ Evennia:
+ GiveLock: Accounts/Things: controls who may give the object.
+ Evennia:
+ LeaveLock: Accounts/Things: controls who may LEAVE the object.
+ Evennia:
+ LinkLock: All but Exits: controls who may link to the location
+ if the location is LINK_OK (for linking
+ exits or setting drop-tos) or ABODE (for
+ setting homes)
+ Evennia:
+ MailLock: Accounts: controls who may @mail the account.
+ Evennia:
+ OpenLock: All but Exits: controls who may open an exit.
+ Evennia:
+ PageLock: Accounts: controls who may page the account.
+ Evennia: "send:<lockfunc()>"
+ ParentLock: All: controls who may make @parent links to
+ the object.
+ Evennia: Typeclasses and
+ "puppet:<lockstring()>"
+ ReceiveLock: Accounts/Things: controls who may give things to the
+ object.
+ Evennia:
+ SpeechLock: All but Exits: controls who may speak in that location
+ Evennia:
+ TeloutLock: All but Exits: controls who may teleport out of the
+ location.
+ Evennia:
+ TportLock: Rooms/Things: controls who may teleport there
+ Evennia:
+ UseLock: All but Exits: controls who may USE the object, GIVE
+ the object money and have the PAY
+ attributes run, have their messages
+ heard and possibly acted on by LISTEN
+ and AxHEAR, and invoke $-commands
+ stored on the object.
+ Evennia: Commands and Cmdsets.
+ DropLock: All but rooms: controls who may drop that object.
+ Evennia:
+ VisibleLock: All: Controls object visibility when the
+ object is not dark and the looker
+ passes the lock. In DARK locations, the
+ object must also be set LIGHT and the
+ viewer must pass the VisibleLock.
+ Evennia: Room typeclass with
+ Dark/light script
+```
+"""
+
+
+fromastimportliteral_eval
+fromdjango.confimportsettings
+fromevennia.utilsimportutils
+
+_PERMISSION_HIERARCHY=[pe.lower()forpeinsettings.PERMISSION_HIERARCHY]
+# also accept different plural forms
+_PERMISSION_HIERARCHY_PLURAL=[
+ pe+"s"ifnotpe.endswith("s")elsepeforpein_PERMISSION_HIERARCHY
+]
+
+
+def_to_account(accessing_obj):
+ "Helper function. Makes sure an accessing object is an account object"
+ ifutils.inherits_from(accessing_obj,"evennia.objects.objects.DefaultObject"):
+ # an object. Convert to account.
+ accessing_obj=accessing_obj.account
+ returnaccessing_obj
+
+
+# lock functions
+
+
+
[docs]defself(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Check if accessing_obj is the same as accessed_obj
+
+ Usage:
+ self()
+
+ This can be used to lock specifically only to
+ the same object that the lock is defined on.
+ """
+ returnaccessing_obj==accessed_obj
+
+
+
[docs]defperm(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ The basic permission-checker. Ignores case.
+
+ Usage:
+ perm(<permission>)
+
+ where <permission> is the permission accessing_obj must
+ have in order to pass the lock.
+
+ If the given permission is part of settings.PERMISSION_HIERARCHY,
+ permission is also granted to all ranks higher up in the hierarchy.
+
+ If accessing_object is an Object controlled by an Account, the
+ permissions of the Account is used unless the Attribute _quell
+ is set to True on the Object. In this case however, the
+ LOWEST hieararcy-permission of the Account/Object-pair will be used
+ (this is order to avoid Accounts potentially escalating their own permissions
+ by use of a higher-level Object)
+
+ """
+ # this allows the perm_above lockfunc to make use of this function too
+ try:
+ permission=args[0].lower()
+ perms_object=accessing_obj.permissions.all()
+ except(AttributeError,IndexError):
+ returnFalse
+
+ gtmode=kwargs.pop("_greater_than",False)
+ is_quell=False
+
+ account=(
+ utils.inherits_from(accessing_obj,"evennia.objects.objects.DefaultObject")
+ andaccessing_obj.account
+ )
+ # check object perms (note that accessing_obj could be an Account too)
+ perms_account=[]
+ ifaccount:
+ perms_account=account.permissions.all()
+ is_quell=account.attributes.get("_quell")
+
+ # Check hirarchy matches; handle both singular/plural forms in hierarchy
+ hpos_target=None
+ ifpermissionin_PERMISSION_HIERARCHY:
+ hpos_target=_PERMISSION_HIERARCHY.index(permission)
+ ifpermission.endswith("s")andpermission[:-1]in_PERMISSION_HIERARCHY:
+ hpos_target=_PERMISSION_HIERARCHY.index(permission[:-1])
+ ifhpos_targetisnotNone:
+ # hieratchy match
+ hpos_account=-1
+ hpos_object=-1
+
+ ifaccount:
+ # we have an account puppeting this object. We must check what perms it has
+ perms_account_single=[p[:-1]ifp.endswith("s")elsepforpinperms_account]
+ hpos_account=[
+ hpos
+ forhpos,hperminenumerate(_PERMISSION_HIERARCHY)
+ ifhperminperms_account_single
+ ]
+ hpos_account=hpos_accountandhpos_account[-1]or-1
+
+ ifnotaccountoris_quell:
+ # only get the object-level perms if there is no account or quelling
+ perms_object_single=[p[:-1]ifp.endswith("s")elsepforpinperms_object]
+ hpos_object=[
+ hpos
+ forhpos,hperminenumerate(_PERMISSION_HIERARCHY)
+ ifhperminperms_object_single
+ ]
+ hpos_object=hpos_objectandhpos_object[-1]or-1
+
+ ifaccountandis_quell:
+ # quell mode: use smallest perm from account and object
+ ifgtmode:
+ returnhpos_target<min(hpos_account,hpos_object)
+ else:
+ returnhpos_target<=min(hpos_account,hpos_object)
+ elifaccount:
+ # use account perm
+ ifgtmode:
+ returnhpos_target<hpos_account
+ else:
+ returnhpos_target<=hpos_account
+ else:
+ # use object perm
+ ifgtmode:
+ returnhpos_target<hpos_object
+ else:
+ returnhpos_target<=hpos_object
+ else:
+ # no hierarchy match - check direct matches
+ ifaccount:
+ # account exists, check it first unless quelled
+ ifis_quellandpermissioninperms_object:
+ returnTrue
+ elifpermissioninperms_account:
+ returnTrue
+ elifpermissioninperms_object:
+ returnTrue
+
+ returnFalse
+
+
+
[docs]defperm_above(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Only allow objects with a permission *higher* in the permission
+ hierarchy than the one given. If there is no such higher rank,
+ it's assumed we refer to superuser. If no hierarchy is defined,
+ this function has no meaning and returns False.
+ """
+ kwargs["_greater_than"]=True
+ returnperm(accessing_obj,accessed_obj,*args,**kwargs)
+
+
+
[docs]defpperm(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ The basic permission-checker only for Account objects. Ignores case.
+
+ Usage:
+ pperm(<permission>)
+
+ where <permission> is the permission accessing_obj must
+ have in order to pass the lock. If the given permission
+ is part of _PERMISSION_HIERARCHY, permission is also granted
+ to all ranks higher up in the hierarchy.
+ """
+ returnperm(_to_account(accessing_obj),accessed_obj,*args,**kwargs)
+
+
+
[docs]defpperm_above(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Only allow Account objects with a permission *higher* in the permission
+ hierarchy than the one given. If there is no such higher rank,
+ it's assumed we refer to superuser. If no hierarchy is defined,
+ this function has no meaning and returns False.
+ """
+ returnperm_above(_to_account(accessing_obj),accessed_obj,*args,**kwargs)
+
+
+
[docs]defdbref(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ dbref(3)
+
+ This lock type checks if the checking object
+ has a particular dbref. Note that this only
+ works for checking objects that are stored
+ in the database (e.g. not for commands)
+ """
+ ifnotargs:
+ returnFalse
+ try:
+ dbr=int(args[0].strip().strip("#"))
+ exceptValueError:
+ returnFalse
+ ifhasattr(accessing_obj,"dbid"):
+ returndbr==accessing_obj.dbid
+ returnFalse
+
+
+
[docs]defpdbref(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Same as dbref, but making sure accessing_obj is an account.
+ """
+ returndbref(_to_account(accessing_obj),accessed_obj,*args,**kwargs)
+
+
+
[docs]defid(accessing_obj,accessed_obj,*args,**kwargs):
+ "Alias to dbref"
+ returndbref(accessing_obj,accessed_obj,*args,**kwargs)
+
+
+
[docs]defpid(accessing_obj,accessed_obj,*args,**kwargs):
+ "Alias to dbref, for Accounts"
+ returndbref(_to_account(accessing_obj),accessed_obj,*args,**kwargs)
+
+
+# this is more efficient than multiple if ... elif statments
+CF_MAPPING={
+ "eq":lambdaval1,val2:val1==val2orstr(val1)==str(val2)orfloat(val1)==float(val2),
+ "gt":lambdaval1,val2:float(val1)>float(val2),
+ "lt":lambdaval1,val2:float(val1)<float(val2),
+ "ge":lambdaval1,val2:float(val1)>=float(val2),
+ "le":lambdaval1,val2:float(val1)<=float(val2),
+ "ne":lambdaval1,val2:float(val1)!=float(val2),
+ "default":lambdaval1,val2:False,
+}
+
+
+
[docs]defattr(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ attr(attrname)
+ attr(attrname, value)
+ attr(attrname, value, compare=type)
+
+ where compare's type is one of (eq,gt,lt,ge,le,ne) and signifies
+ how the value should be compared with one on accessing_obj (so
+ compare=gt means the accessing_obj must have a value greater than
+ the one given).
+
+ Searches attributes *and* properties stored on the accessing_obj.
+ if accessing_obj has a property "obj", then this is used as
+ accessing_obj (this makes this usable for Commands too)
+
+ The first form works like a flag - if the attribute/property
+ exists on the object, the value is checked for True/False. The
+ second form also requires that the value of the attribute/property
+ matches. Note that all retrieved values will be converted to
+ strings before doing the comparison.
+ """
+ # deal with arguments
+ ifnotargs:
+ returnFalse
+ attrname=args[0].strip()
+ value=None
+ iflen(args)>1:
+ value=args[1].strip()
+ compare="eq"
+ ifkwargs:
+ compare=kwargs.get("compare","eq")
+
+ defvalcompare(val1,val2,typ="eq"):
+ "compare based on type"
+ try:
+ returnCF_MAPPING.get(typ,CF_MAPPING["default"])(val1,val2)
+ exceptException:
+ # this might happen if we try to compare two things that
+ # cannot be compared
+ returnFalse
+
+ ifhasattr(accessing_obj,"obj"):
+ # NOTE: this is relevant for Commands. It may clash with scripts
+ # (they have Attributes and .obj) , but are scripts really
+ # used so that one ever wants to check the property on the
+ # Script rather than on its owner?
+ accessing_obj=accessing_obj.obj
+
+ # first, look for normal properties on the object trying to gain access
+ ifhasattr(accessing_obj,attrname):
+ ifvalue:
+ returnvalcompare(str(getattr(accessing_obj,attrname)),value,compare)
+ # will return Fail on False value etc
+ returnbool(getattr(accessing_obj,attrname))
+ # check attributes, if they exist
+ ifhasattr(accessing_obj,"attributes")andaccessing_obj.attributes.has(attrname):
+ ifvalue:
+ returnhasattr(accessing_obj,"attributes")andvalcompare(
+ accessing_obj.attributes.get(attrname),value,compare
+ )
+ # fails on False/None values
+ returnbool(accessing_obj.attributes.get(attrname))
+ returnFalse
+
+
+
[docs]defobjattr(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ objattr(attrname)
+ objattr(attrname, value)
+ objattr(attrname, value, compare=type)
+
+ Works like attr, except it looks for an attribute on
+ accessed_obj instead.
+
+ """
+ returnattr(accessed_obj,accessed_obj,*args,**kwargs)
+
+
+
[docs]deflocattr(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ locattr(attrname)
+ locattr(attrname, value)
+ locattr(attrname, value, compare=type)
+
+ Works like attr, except it looks for an attribute on
+ accessing_obj.location, if such an entity exists.
+
+ if accessing_obj has a property ".obj" (such as is the case for a
+ Command), then accessing_obj.obj.location is used instead.
+
+ """
+ ifhasattr(accessing_obj,"obj"):
+ accessing_obj=accessing_obj.obj
+ ifhasattr(accessing_obj,"location"):
+ returnattr(accessing_obj.location,accessed_obj,*args,**kwargs)
+ returnFalse
+
+
+
[docs]defobjlocattr(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ locattr(attrname)
+ locattr(attrname, value)
+ locattr(attrname, value, compare=type)
+
+ Works like attr, except it looks for an attribute on
+ accessed_obj.location, if such an entity exists.
+
+ if accessed_obj has a property ".obj" (such as is the case for a
+ Command), then accessing_obj.obj.location is used instead.
+
+ """
+ ifhasattr(accessed_obj,"obj"):
+ accessed_obj=accessed_obj.obj
+ ifhasattr(accessed_obj,"location"):
+ returnattr(accessed_obj.location,accessed_obj,*args,**kwargs)
+ returnFalse
[docs]defattr_gt(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ attr_gt(attrname, 54)
+
+ Only true if access_obj's attribute > the value given.
+ """
+ returnattr(accessing_obj,accessed_obj,*args,**{"compare":"gt"})
+
+
+
[docs]defattr_ge(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ attr_gt(attrname, 54)
+
+ Only true if access_obj's attribute >= the value given.
+ """
+ returnattr(accessing_obj,accessed_obj,*args,**{"compare":"ge"})
+
+
+
[docs]defattr_lt(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ attr_gt(attrname, 54)
+
+ Only true if access_obj's attribute < the value given.
+ """
+ returnattr(accessing_obj,accessed_obj,*args,**{"compare":"lt"})
+
+
+
[docs]defattr_le(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ attr_gt(attrname, 54)
+
+ Only true if access_obj's attribute <= the value given.
+ """
+ returnattr(accessing_obj,accessed_obj,*args,**{"compare":"le"})
+
+
+
[docs]defattr_ne(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ attr_gt(attrname, 54)
+
+ Only true if access_obj's attribute != the value given.
+ """
+ returnattr(accessing_obj,accessed_obj,*args,**{"compare":"ne"})
+
+
+
[docs]deftag(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ tag(tagkey)
+ tag(tagkey, category)
+
+ Only true if accessing_obj has the specified tag and optional
+ category.
+ If accessing_obj has the ".obj" property (such as is the case for
+ a command), then accessing_obj.obj is used instead.
+ """
+ ifhasattr(accessing_obj,"obj"):
+ accessing_obj=accessing_obj.obj
+ tagkey=args[0]ifargselseNone
+ category=args[1]iflen(args)>1elseNone
+ returnbool(accessing_obj.tags.get(tagkey,category=category))
+
+
+
[docs]defobjtag(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ objtag(tagkey)
+ objtag(tagkey, category)
+
+ Only true if accessed_obj has the specified tag and optional
+ category.
+ """
+ ifhasattr(accessed_obj,"obj"):
+ accessed_obj=accessed_obj.obj
+ tagkey=args[0]ifargselseNone
+ category=args[1]iflen(args)>1elseNone
+ returnbool(accessed_obj.tags.get(tagkey,category=category))
+
+
+
[docs]definside(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ inside()
+
+ True if accessing_obj is 'inside' accessing_obj. Note that this only checks
+ one level down. So if if the lock is on a room, you will pass but not your
+ inventory (since their location is you, not the locked object). If you
+ want also nested objects to pass the lock, use the `insiderecursive`
+ lockfunc.
+ """
+ ifhasattr(accessed_obj,"obj"):
+ accessed_obj=accessed_obj.obj
+ returnaccessing_obj.location==accessed_obj
+
+
+
[docs]definside_rec(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ inside_rec()
+
+ True if accessing_obj is inside the accessed obj, at up to 10 levels
+ of recursion (so if this lock is on a room, then an object inside a box
+ in your inventory will also pass the lock).
+ """
+
+ ifhasattr(accessed_obj,"obj"):
+ accessed_obj=accessed_obj.obj
+
+ def_recursive_inside(obj,accessed_obj,lvl=1):
+ ifobj.location:
+ ifobj.location==accessed_obj:
+ returnTrue
+ eliflvl>=10:
+ # avoid infinite recursions
+ returnFalse
+ else:
+ return_recursive_inside(obj.location,accessed_obj,lvl+1)
+ returnFalse
+
+ return_recursive_inside(accessing_obj,accessed_obj)
+
+
+
[docs]defholds(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Usage:
+ holds() checks if accessed_obj or accessed_obj.obj
+ is held by accessing_obj
+ holds(key/dbref) checks if accessing_obj holds an object
+ with given key/dbref
+ holds(attrname, value) checks if accessing_obj holds an
+ object with the given attrname and value
+
+ This is passed if accessed_obj is carried by accessing_obj (that is,
+ accessed_obj.location == accessing_obj), or if accessing_obj itself holds
+ an object matching the given key.
+ """
+ try:
+ # commands and scripts don't have contents, so we are usually looking
+ # for the contents of their .obj property instead (i.e. the object the
+ # command/script is attached to).
+ contents=accessing_obj.contents
+ exceptAttributeError:
+ try:
+ contents=accessing_obj.obj.contents
+ exceptAttributeError:
+ returnFalse
+
+ defcheck_holds(objid):
+ # helper function. Compares both dbrefs and keys/aliases.
+ objid=str(objid)
+ dbref=utils.dbref(objid,reqhash=False)
+ ifdbrefandany((Trueforobjincontentsifobj.dbid==dbref)):
+ returnTrue
+ objid=objid.lower()
+ returnany(
+ (
+ True
+ forobjincontents
+ ifobj.key.lower()==objidorobjidin[al.lower()foralinobj.aliases.all()]
+ )
+ )
+
+ ifnotargs:
+ # holds() - check if accessed_obj or accessed_obj.obj is held by accessing_obj
+ try:
+ ifcheck_holds(accessed_obj.dbid):
+ returnTrue
+ exceptException:
+ # we need to catch any trouble here
+ pass
+ returnhasattr(accessed_obj,"obj")andcheck_holds(accessed_obj.obj.dbid)
+ iflen(args)==1:
+ # command is holds(dbref/key) - check if given objname/dbref is held by accessing_ob
+ returncheck_holds(args[0])
+ eliflen(args)>1:
+ # command is holds(attrname, value) check if any held object has the given attribute and value
+ forobjincontents:
+ ifobj.attributes.get(args[0])==args[1]:
+ returnTrue
+ returnFalse
+
+
+
[docs]defsuperuser(*args,**kwargs):
+ """
+ Only accepts an accesing_obj that is superuser (e.g. user #1)
+
+ Since a superuser would not ever reach this check (superusers
+ bypass the lock entirely), any user who gets this far cannot be a
+ superuser, hence we just return False. :)
+ """
+ returnFalse
+
+
+
[docs]defhas_account(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Only returns true if accessing_obj has_account is true, that is,
+ this is an account-controlled object. It fails on actual accounts!
+
+ This is a useful lock for traverse-locking Exits to restrain NPC
+ mobiles from moving outside their areas.
+ """
+ returnhasattr(accessing_obj,"has_account")andaccessing_obj.has_account
+
+
+
[docs]defserversetting(accessing_obj,accessed_obj,*args,**kwargs):
+ """
+ Only returns true if the Evennia settings exists, alternatively has
+ a certain value.
+
+ Usage:
+ serversetting(IRC_ENABLED)
+ serversetting(BASE_SCRIPT_PATH, ['types'])
+
+ A given True/False or integers will be converted properly. Note that
+ everything will enter this function as strings, so they have to be
+ unpacked to their real value. We only support basic properties.
+ """
+ ifnotargsornotargs[0]:
+ returnFalse
+ iflen(args)<2:
+ setting=args[0]
+ val="True"
+ else:
+ setting,val=args[0],args[1]
+ # convert
+ try:
+ val=literal_eval(val)
+ exceptException:
+ # we swallow errors here, lockfuncs has noone to report to
+ returnFalse
+
+ ifsettinginsettings._wrapped.__dict__:
+ returnsettings._wrapped.__dict__[setting]==val
+ returnFalse
+"""
+A *lock* defines access to a particular subsystem or property of
+Evennia. For example, the "owner" property can be impmemented as a
+lock. Or the disability to lift an object or to ban users.
+
+
+A lock consists of three parts:
+
+ - access_type - this defines what kind of access this lock regulates. This
+ just a string.
+ - function call - this is one or many calls to functions that will determine
+ if the lock is passed or not.
+ - lock function(s). These are regular python functions with a special
+ set of allowed arguments. They should always return a boolean depending
+ on if they allow access or not.
+
+A lock function is defined by existing in one of the modules
+listed by settings.LOCK_FUNC_MODULES. It should also always
+take four arguments looking like this:
+
+ funcname(accessing_obj, accessed_obj, *args, **kwargs):
+ [...]
+
+The accessing object is the object wanting to gain access.
+The accessed object is the object this lock resides on
+args and kwargs will hold optional arguments and/or keyword arguments
+to the function as a list and a dictionary respectively.
+
+Example:
+
+ perm(accessing_obj, accessed_obj, *args, **kwargs):
+ "Checking if the object has a particular, desired permission"
+ if args:
+ desired_perm = args[0]
+ return desired_perm in accessing_obj.permissions.all()
+ return False
+
+Lock functions should most often be pretty general and ideally possible to
+re-use and combine in various ways to build clever locks.
+
+
+
+Lock definition ("Lock string")
+
+A lock definition is a string with a special syntax. It is added to
+each object's lockhandler, making that lock available from then on.
+
+The lock definition looks like this:
+
+ 'access_type:[NOT] func1(args)[ AND|OR][NOT] func2() ...'
+
+That is, the access_type, a colon followed by calls to lock functions
+combined with AND or OR. NOT negates the result of the following call.
+
+Example:
+
+ We want to limit who may edit a particular object (let's call this access_type
+for 'edit', it depends on what the command is looking for). We want this to
+only work for those with the Permission 'Builder'. So we use our lock
+function above and define it like this:
+
+ 'edit:perm(Builder)'
+
+Here, the lock-function perm() will be called with the string
+'Builder' (accessing_obj and accessed_obj are added automatically,
+you only need to add the args/kwargs, if any).
+
+If we wanted to make sure the accessing object was BOTH a Builder and a
+GoodGuy, we could use AND:
+
+ 'edit:perm(Builder) AND perm(GoodGuy)'
+
+To allow EITHER Builder and GoodGuys, we replace AND with OR. perm() is just
+one example, the lock function can do anything and compare any properties of
+the calling object to decide if the lock is passed or not.
+
+ 'lift:attrib(very_strong) AND NOT attrib(bad_back)'
+
+To make these work, add the string to the lockhandler of the object you want
+to apply the lock to:
+
+ obj.lockhandler.add('edit:perm(Builder)')
+
+From then on, a command that wants to check for 'edit' access on this
+object would do something like this:
+
+ if not target_obj.lockhandler.has_perm(caller, 'edit'):
+ caller.msg("Sorry, you cannot edit that.")
+
+All objects also has a shortcut called 'access' that is recommended to
+use instead:
+
+ if not target_obj.access(caller, 'edit'):
+ caller.msg("Sorry, you cannot edit that.")
+
+
+Permissions
+
+Permissions are just text strings stored in a comma-separated list on
+typeclassed objects. The default perm() lock function uses them,
+taking into account settings.PERMISSION_HIERARCHY. Also, the
+restricted @perm command sets them, but otherwise they are identical
+to any other identifier you can use.
+
+"""
+
+importre
+fromdjango.confimportsettings
+fromevennia.utilsimportlogger,utils
+fromdjango.utils.translationimportgettextas_
+
+__all__=("LockHandler","LockException")
+
+WARNING_LOG=settings.LOCKWARNING_LOG_FILE
+_LOCK_HANDLER=None
+
+
+#
+# Exception class. This will be raised
+# by errors in lock definitions.
+#
+
+
+
[docs]classLockException(Exception):
+ """
+ Raised during an error in a lock.
+ """
+
+ pass
[docs]classLockHandler(object):
+ """
+ This handler should be attached to all objects implementing
+ permission checks, under the property 'lockhandler'.
+
+ """
+
+
[docs]def__init__(self,obj):
+ """
+ Loads and pre-caches all relevant locks and their functions.
+
+ Args:
+ obj (object): The object on which the lockhandler is
+ defined.
+
+ """
+ ifnot_LOCKFUNCS:
+ _cache_lockfuncs()
+ self.obj=obj
+ self.locks={}
+ try:
+ self.reset()
+ exceptLockExceptionaserr:
+ logger.log_trace(err)
+
+ def__str__(self):
+ return";".join(self.locks[key][2]forkeyinsorted(self.locks))
+
+ def_log_error(self,message):
+ "Try to log errors back to object"
+ raiseLockException(message)
+
+ def_parse_lockstring(self,storage_lockstring):
+ """
+ Helper function. This is normally only called when the
+ lockstring is cached and does preliminary checking. locks are
+ stored as a string
+
+ atype:[NOT] lock()[[ AND|OR [NOT] lock()[...]];atype...
+
+ Args:
+ storage_locksring (str): The lockstring to parse.
+
+ """
+ locks={}
+ ifnotstorage_lockstring:
+ returnlocks
+ duplicates=0
+ elist=[]# errors
+ wlist=[]# warnings
+ forraw_lockstringinstorage_lockstring.split(";"):
+ ifnotraw_lockstring:
+ continue
+ lock_funcs=[]
+ try:
+ access_type,rhs=(part.strip()forpartinraw_lockstring.split(":",1))
+ exceptValueError:
+ logger.log_trace()
+ returnlocks
+
+ # parse the lock functions and separators
+ funclist=_RE_FUNCS.findall(rhs)
+ evalstring=rhs
+ forpatternin("AND","OR","NOT"):
+ evalstring=re.sub(r"\b%s\b"%pattern,pattern.lower(),evalstring)
+ nfuncs=len(funclist)
+ forfuncstringinfunclist:
+ funcname,rest=(part.strip().strip(")")forpartinfuncstring.split("(",1))
+ func=_LOCKFUNCS.get(funcname,None)
+ ifnotcallable(func):
+ elist.append(_("Lock: lock-function '%s' is not available.")%funcstring)
+ continue
+ args=list(arg.strip()forarginrest.split(",")ifargand"="notinarg)
+ kwargs=dict(
+ [
+ (part.strip()forpartinarg.split("=",1))
+ forarginrest.split(",")
+ ifargand"="inarg
+ ]
+ )
+ lock_funcs.append((func,args,kwargs))
+ evalstring=evalstring.replace(funcstring,"%s")
+ iflen(lock_funcs)<nfuncs:
+ continue
+ try:
+ # purge the eval string of any superfluous items, then test it
+ evalstring=" ".join(_RE_OK.findall(evalstring))
+ eval(evalstring%tuple(Trueforfuncinfunclist),{},{})
+ exceptException:
+ elist.append(
+ _("Lock: definition '{lock_string}' has syntax errors.").format(
+ lock_string=raw_lockstring
+ )
+ )
+ continue
+ ifaccess_typeinlocks:
+ duplicates+=1
+ wlist.append(
+ _(
+ "LockHandler on %(obj)s: access type '%(access_type)s' changed from '%(source)s' to '%(goal)s' "
+ %{
+ "obj":self.obj,
+ "access_type":access_type,
+ "source":locks[access_type][2],
+ "goal":raw_lockstring,
+ }
+ )
+ )
+ locks[access_type]=(evalstring,tuple(lock_funcs),raw_lockstring)
+ ifwlistandWARNING_LOG:
+ # a warning text was set, it's not an error, so only report
+ logger.log_file("\n".join(wlist),WARNING_LOG)
+ ifelist:
+ # an error text was set, raise exception.
+ raiseLockException("\n".join(elist))
+ # return the gathered locks in an easily executable form
+ returnlocks
+
+ def_cache_locks(self,storage_lockstring):
+ """
+ Store data
+ """
+ self.locks=self._parse_lockstring(storage_lockstring)
+
+ def_save_locks(self):
+ """
+ Store locks to obj
+ """
+ self.obj.lock_storage=";".join([tup[2]fortupinself.locks.values()])
+
+
[docs]defcache_lock_bypass(self,obj):
+ """
+ We cache superuser bypass checks here for efficiency. This
+ needs to be re-run when an account is assigned to a character.
+ We need to grant access to superusers. We need to check both
+ directly on the object (accounts), through obj.account and using
+ the get_account() method (this sits on serversessions, in some
+ rare cases where a check is done before the login process has
+ yet been fully finalized)
+
+ Args:
+ obj (object): This is checked for the `is_superuser` property.
+
+ """
+ self.lock_bypass=hasattr(obj,"is_superuser")andobj.is_superuser
+
+
[docs]defadd(self,lockstring,validate_only=False):
+ """
+ Add a new lockstring to handler.
+
+ Args:
+ lockstring (str or list): A string on the form
+ `"<access_type>:<functions>"`. Multiple access types
+ should be separated by semicolon (`;`). Alternatively,
+ a list with lockstrings.
+ validate_only (bool, optional): If True, validate the lockstring but
+ don't actually store it.
+ Returns:
+ success (bool): The outcome of the addition, `False` on
+ error. If `validate_only` is True, this will be a tuple
+ (bool, error), for pass/fail and a string error.
+
+ """
+ ifisinstance(lockstring,str):
+ lockdefs=lockstring.split(";")
+ else:
+ lockdefs=[lockdefforlocksinlockstringforlockdefinlocks.split(";")]
+ lockstring=";".join(lockdefs)
+
+ err=""
+ # sanity checks
+ forlockdefinlockdefs:
+ if":"notinlockdef:
+ err=_("Lock: '{lockdef}' contains no colon (:).").format(lockdef=lockdef)
+ ifvalidate_only:
+ returnFalse,err
+ else:
+ self._log_error(err)
+ returnFalse
+ access_type,rhs=[part.strip()forpartinlockdef.split(":",1)]
+ ifnotaccess_type:
+ err=_(
+ "Lock: '{lockdef}' has no access_type ""(left-side of colon is empty)."
+ ).format(lockdef=lockdef)
+ ifvalidate_only:
+ returnFalse,err
+ else:
+ self._log_error(err)
+ returnFalse
+ ifrhs.count("(")!=rhs.count(")"):
+ err=_("Lock: '{lockdef}' has mismatched parentheses.").format(lockdef=lockdef)
+ ifvalidate_only:
+ returnFalse,err
+ else:
+ self._log_error(err)
+ returnFalse
+ ifnot_RE_FUNCS.findall(rhs):
+ err=_("Lock: '{lockdef}' has no valid lock functions.").format(lockdef=lockdef)
+ ifvalidate_only:
+ returnFalse,err
+ else:
+ self._log_error(err)
+ returnFalse
+ ifvalidate_only:
+ returnTrue,None
+ # get the lock string
+ storage_lockstring=self.obj.lock_storage
+ ifstorage_lockstring:
+ storage_lockstring=storage_lockstring+";"+lockstring
+ else:
+ storage_lockstring=lockstring
+ # cache the locks will get rid of eventual doublets
+ self._cache_locks(storage_lockstring)
+ self._save_locks()
+ returnTrue
+
+
[docs]defvalidate(self,lockstring):
+ """
+ Validate lockstring syntactically, without saving it.
+
+ Args:
+ lockstring (str): Lockstring to validate.
+ Returns:
+ valid (bool): If validation passed or not.
+
+ """
+ returnself.add(lockstring,validate_only=True)
+
+
[docs]defreplace(self,lockstring):
+ """
+ Replaces the lockstring entirely.
+
+ Args:
+ lockstring (str): The new lock definition.
+
+ Return:
+ success (bool): False if an error occurred.
+
+ Raises:
+ LockException: If a critical error occurred.
+ If so, the old string is recovered.
+
+ """
+ old_lockstring=str(self)
+ self.clear()
+ try:
+ returnself.add(lockstring)
+ exceptLockException:
+ self.add(old_lockstring)
+ raise
+
+
[docs]defget(self,access_type=None):
+ """
+ Get the full lockstring or the lockstring of a particular
+ access type.
+
+ Args:
+ access_type (str, optional):
+
+ Returns:
+ lockstring (str): The matched lockstring, or the full
+ lockstring if no access_type was given.
+ """
+
+ ifaccess_type:
+ returnself.locks.get(access_type,["","",""])[2]
+ returnstr(self)
+
+
[docs]defall(self):
+ """
+ Return all lockstrings
+
+ Returns:
+ lockstrings (list): All separate lockstrings
+
+ """
+ returnstr(self).split(";")
+
+
[docs]defremove(self,access_type):
+ """
+ Remove a particular lock from the handler
+
+ Args:
+ access_type (str): The type of lock to remove.
+
+ Returns:
+ success (bool): If the access_type was not found
+ in the lock, this returns `False`.
+
+ """
+ ifaccess_typeinself.locks:
+ delself.locks[access_type]
+ self._save_locks()
+ returnTrue
+ returnFalse
+
+ delete=remove# alias for historical reasons
+
+
[docs]defclear(self):
+ """
+ Remove all locks in the handler.
+
+ """
+ self.locks={}
+ self.lock_storage=""
+ self._save_locks()
+
+
[docs]defreset(self):
+ """
+ Set the reset flag, so the the lock will be re-cached at next
+ checking. This is usually called by @reload.
+
+ """
+ self._cache_locks(self.obj.lock_storage)
+ self.cache_lock_bypass(self.obj)
+
+
[docs]defappend(self,access_type,lockstring,op="or"):
+ """
+ Append a lock definition to access_type if it doesn't already exist.
+
+ Args:
+ access_type (str): Access type.
+ lockstring (str): A valid lockstring, without the operator to
+ link it to an eventual existing lockstring.
+ op (str): An operator 'and', 'or', 'and not', 'or not' used
+ for appending the lockstring to an existing access-type.
+ Note:
+ The most common use of this method is for use in commands where
+ the user can specify their own lockstrings. This method allows
+ the system to auto-add things like Admin-override access.
+
+ """
+ old_lockstring=self.get(access_type)
+ ifnotlockstring.strip().lower()inold_lockstring.lower():
+ lockstring="{old}{op}{new}".format(
+ old=old_lockstring,op=op,new=lockstring.strip()
+ )
+ self.add(lockstring)
+
+
[docs]defcheck(self,accessing_obj,access_type,default=False,no_superuser_bypass=False):
+ """
+ Checks a lock of the correct type by passing execution off to
+ the lock function(s).
+
+ Args:
+ accessing_obj (object): The object seeking access.
+ access_type (str): The type of access wanted.
+ default (bool, optional): If no suitable lock type is
+ found, default to this result.
+ no_superuser_bypass (bool): Don't use this unless you
+ really, really need to, it makes supersusers susceptible
+ to the lock check.
+
+ Notes:
+ A lock is executed in the follwoing way:
+
+ Parsing the lockstring, we (during cache) extract the valid
+ lock functions and store their function objects in the right
+ order along with their args/kwargs. These are now executed in
+ sequence, creating a list of True/False values. This is put
+ into the evalstring, which is a string of AND/OR/NOT entries
+ separated by placeholders where each function result should
+ go. We just put those results in and evaluate the string to
+ get a final, combined True/False value for the lockstring.
+
+ The important bit with this solution is that the full
+ lockstring is never blindly evaluated, and thus there (should
+ be) no way to sneak in malign code in it. Only "safe" lock
+ functions (as defined by your settings) are executed.
+
+ """
+ try:
+ # check if the lock should be bypassed (e.g. superuser status)
+ ifaccessing_obj.locks.lock_bypassandnotno_superuser_bypass:
+ returnTrue
+ exceptAttributeError:
+ # happens before session is initiated.
+ ifnotno_superuser_bypassand(
+ (hasattr(accessing_obj,"is_superuser")andaccessing_obj.is_superuser)
+ or(
+ hasattr(accessing_obj,"account")
+ andhasattr(accessing_obj.account,"is_superuser")
+ andaccessing_obj.account.is_superuser
+ )
+ or(
+ hasattr(accessing_obj,"get_account")
+ and(
+ notaccessing_obj.get_account()oraccessing_obj.get_account().is_superuser
+ )
+ )
+ ):
+ returnTrue
+
+ # no superuser or bypass -> normal lock operation
+ ifaccess_typeinself.locks:
+ # we have a lock, test it.
+ evalstring,func_tup,raw_string=self.locks[access_type]
+ # execute all lock funcs in the correct order, producing a tuple of True/False results.
+ true_false=tuple(
+ bool(tup[0](accessing_obj,self.obj,*tup[1],**tup[2]))fortupinfunc_tup
+ )
+ # the True/False tuple goes into evalstring, which combines them
+ # with AND/OR/NOT in order to get the final result.
+ returneval(evalstring%true_false)
+ else:
+ returndefault
+
+ def_eval_access_type(self,accessing_obj,locks,access_type):
+ """
+ Helper method for evaluating the access type using eval().
+
+ Args:
+ accessing_obj (object): Object seeking access.
+ locks (dict): The pre-parsed representation of all access-types.
+ access_type (str): An access-type key to evaluate.
+
+ """
+ evalstring,func_tup,raw_string=locks[access_type]
+ true_false=tuple(tup[0](accessing_obj,self.obj,*tup[1],**tup[2])fortupinfunc_tup)
+ returneval(evalstring%true_false)
+
+
[docs]defcheck_lockstring(
+ self,accessing_obj,lockstring,no_superuser_bypass=False,default=False,access_type=None
+ ):
+ """
+ Do a direct check against a lockstring ('atype:func()..'),
+ without any intermediary storage on the accessed object.
+
+ Args:
+ accessing_obj (object or None): The object seeking access.
+ Importantly, this can be left unset if the lock functions
+ don't access it, no updating or storage of locks are made
+ against this object in this method.
+ lockstring (str): Lock string to check, on the form
+ `"access_type:lock_definition"` where the `access_type`
+ part can potentially be set to a dummy value to just check
+ a lock condition.
+ no_superuser_bypass (bool, optional): Force superusers to heed lock.
+ default (bool, optional): Fallback result to use if `access_type` is set
+ but no such `access_type` is found in the given `lockstring`.
+ access_type (str, bool): If set, only this access_type will be looked up
+ among the locks defined by `lockstring`.
+
+ Return:
+ access (bool): If check is passed or not.
+
+ """
+ try:
+ ifaccessing_obj.locks.lock_bypassandnotno_superuser_bypass:
+ returnTrue
+ exceptAttributeError:
+ ifno_superuser_bypassand(
+ (hasattr(accessing_obj,"is_superuser")andaccessing_obj.is_superuser)
+ or(
+ hasattr(accessing_obj,"account")
+ andhasattr(accessing_obj.account,"is_superuser")
+ andaccessing_obj.account.is_superuser
+ )
+ or(
+ hasattr(accessing_obj,"get_account")
+ and(
+ notaccessing_obj.get_account()oraccessing_obj.get_account().is_superuser
+ )
+ )
+ ):
+ returnTrue
+ if":"notinlockstring:
+ lockstring="%s:%s"%("_dummy",lockstring)
+
+ locks=self._parse_lockstring(lockstring)
+
+ ifaccess_type:
+ ifaccess_typenotinlocks:
+ returndefault
+ else:
+ returnself._eval_access_type(accessing_obj,locks,access_type)
+ else:
+ # if no access types was given and multiple locks were
+ # embedded in the lockstring we assume all must be true
+ returnall(
+ self._eval_access_type(accessing_obj,locks,access_type)foraccess_typeinlocks
+ )
+
+
+# convenience access function
+
+# dummy to be able to call check_lockstring from the outside
+
+
+class_ObjDummy:
+ lock_storage=""
+
+
+defcheck_lockstring(
+ accessing_obj,lockstring,no_superuser_bypass=False,default=False,access_type=None
+):
+ """
+ Do a direct check against a lockstring ('atype:func()..'),
+ without any intermediary storage on the accessed object.
+
+ Args:
+ accessing_obj (object or None): The object seeking access.
+ Importantly, this can be left unset if the lock functions
+ don't access it, no updating or storage of locks are made
+ against this object in this method.
+ lockstring (str): Lock string to check, on the form
+ `"access_type:lock_definition"` where the `access_type`
+ part can potentially be set to a dummy value to just check
+ a lock condition.
+ no_superuser_bypass (bool, optional): Force superusers to heed lock.
+ default (bool, optional): Fallback result to use if `access_type` is set
+ but no such `access_type` is found in the given `lockstring`.
+ access_type (str, bool): If set, only this access_type will be looked up
+ among the locks defined by `lockstring`.
+
+ Return:
+ access (bool): If check is passed or not.
+
+ """
+ global_LOCK_HANDLER
+ ifnot_LOCK_HANDLER:
+ _LOCK_HANDLER=LockHandler(_ObjDummy())
+ return_LOCK_HANDLER.check_lockstring(
+ accessing_obj,
+ lockstring,
+ no_superuser_bypass=no_superuser_bypass,
+ default=default,
+ access_type=access_type,
+ )
+
+
+defvalidate_lockstring(lockstring):
+ """
+ Validate so lockstring is on a valid form.
+
+ Args:
+ lockstring (str): Lockstring to validate.
+
+ Returns:
+ is_valid (bool): If the lockstring is valid or not.
+ error (str or None): A string describing the error, or None
+ if no error was found.
+
+ """
+ global_LOCK_HANDLER
+ ifnot_LOCK_HANDLER:
+ _LOCK_HANDLER=LockHandler(_ObjDummy())
+ return_LOCK_HANDLER.validate(lockstring)
+
+
+defget_all_lockfuncs():
+ """
+ Get a dict of available lock funcs.
+
+ Returns:
+ lockfuncs (dict): Mapping {lockfuncname:func}.
+
+ """
+ ifnot_LOCKFUNCS:
+ _cache_lockfuncs()
+ return_LOCKFUNCS
+
+
+def_test():
+ # testing
+
+ classTestObj(object):
+ pass
+
+ importpdb
+
+ obj1=TestObj()
+ obj2=TestObj()
+
+ # obj1.lock_storage = "owner:dbref(#4);edit:dbref(#5) or perm(Admin);examine:perm(Builder);delete:perm(Admin);get:all()"
+ # obj1.lock_storage = "cmd:all();admin:id(1);listen:all();send:all()"
+ obj1.lock_storage="listen:perm(Developer)"
+
+ pdb.set_trace()
+ obj1.locks=LockHandler(obj1)
+ obj2.permissions.add("Developer")
+ obj2.id=4
+
+ # obj1.locks.add("edit:attr(test)")
+
+ print("comparing obj2.permissions (%s) vs obj1.locks (%s)"%(obj2.permissions,obj1.locks))
+ print(obj1.locks.check(obj2,"owner"))
+ print(obj1.locks.check(obj2,"edit"))
+ print(obj1.locks.check(obj2,"examine"))
+ print(obj1.locks.check(obj2,"delete"))
+ print(obj1.locks.check(obj2,"get"))
+ print(obj1.locks.check(obj2,"listen"))
+
+#
+# This sets up how models are displayed
+# in the web admin interface.
+#
+fromdjangoimportforms
+fromdjango.confimportsettings
+fromdjango.contribimportadmin
+fromevennia.typeclasses.adminimportAttributeInline,TagInline
+fromevennia.objects.modelsimportObjectDB
+fromdjango.contrib.admin.utilsimportflatten_fieldsets
+fromdjango.utils.translationimportgettextas_
+
+
+
+
+ db_key=forms.CharField(
+ label="Name/Key",
+ widget=forms.TextInput(attrs={"size":"78"}),
+ help_text="Main identifier, like 'apple', 'strong guy', 'Elizabeth' etc. "
+ "If creating a Character, check so the name is unique among characters!",
+ )
+ db_typeclass_path=forms.CharField(
+ label="Typeclass",
+ initial=settings.BASE_OBJECT_TYPECLASS,
+ widget=forms.TextInput(attrs={"size":"78"}),
+ help_text="This defines what 'type' of entity this is. This variable holds a "
+ "Python path to a module with a valid Evennia Typeclass. If you are "
+ "creating a Character you should use the typeclass defined by "
+ "settings.BASE_CHARACTER_TYPECLASS or one derived from that.",
+ )
+ db_cmdset_storage=forms.CharField(
+ label="CmdSet",
+ initial="",
+ required=False,
+ widget=forms.TextInput(attrs={"size":"78"}),
+ help_text="Most non-character objects don't need a cmdset"
+ " and can leave this field blank.",
+ )
+ raw_id_fields=("db_destination","db_location","db_home")
+
+
+
[docs]classObjectEditForm(ObjectCreateForm):
+ """
+ Form used for editing. Extends the create one with more fields
+
+ """
+
+
+
+ db_lock_storage=forms.CharField(
+ label="Locks",
+ required=False,
+ widget=forms.Textarea(attrs={"cols":"100","rows":"2"}),
+ help_text="In-game lock definition string. If not given, defaults will be used. "
+ "This string should be on the form "
+ "<i>type:lockfunction(args);type2:lockfunction2(args);...",
+ )
[docs]defget_form(self,request,obj=None,**kwargs):
+ """
+ Use special form during creation.
+
+ Args:
+ request (Request): Incoming request.
+ obj (Object, optional): Database object.
+
+ """
+ defaults={}
+ ifobjisNone:
+ defaults.update(
+ {"form":self.add_form,"fields":flatten_fieldsets(self.add_fieldsets)}
+ )
+ defaults.update(kwargs)
+ returnsuper().get_form(request,obj,**defaults)
+
+
[docs]defsave_model(self,request,obj,form,change):
+ """
+ Model-save hook.
+
+ Args:
+ request (Request): Incoming request.
+ obj (Object): Database object.
+ form (Form): Form instance.
+ change (bool): If this is a change or a new object.
+
+ """
+ obj.save()
+ ifnotchange:
+ # adding a new object
+ # have to call init with typeclass passed to it
+ obj.set_class_from_typeclass(typeclass_path=obj.db_typeclass_path)
+ obj.basetype_setup()
+ obj.basetype_posthook_setup()
+ obj.at_object_creation()
+ obj.at_init()
+"""
+Custom manager for Objects.
+"""
+importre
+fromitertoolsimportchain
+fromdjango.db.modelsimportQ
+fromdjango.confimportsettings
+fromdjango.db.models.fieldsimportexceptions
+fromevennia.typeclasses.managersimportTypedObjectManager,TypeclassManager
+fromevennia.utils.utilsimportis_iter,make_iter,string_partial_matching
+
+__all__=("ObjectManager","ObjectDBManager")
+_GA=object.__getattribute__
+
+# delayed import
+_ATTR=None
+
+_MULTIMATCH_REGEX=re.compile(settings.SEARCH_MULTIMATCH_REGEX,re.I+re.U)
+
+# Try to use a custom way to parse id-tagged multimatches.
+
+
+
[docs]classObjectDBManager(TypedObjectManager):
+ """
+ This ObjectManager implements methods for searching
+ and manipulating Objects directly from the database.
+
+ Evennia-specific search methods (will return Typeclasses or
+ lists of Typeclasses, whereas Django-general methods will return
+ Querysets or database objects).
+
+ dbref (converter)
+ get_id (alias: dbref_search)
+ get_dbref_range
+ object_totals
+ typeclass_search
+ get_object_with_account
+ get_objs_with_key_and_typeclass
+ get_objs_with_attr
+ get_objs_with_attr_match
+ get_objs_with_db_property
+ get_objs_with_db_property_match
+ get_objs_with_key_or_alias
+ get_contents
+ object_search (interface to many of the above methods,
+ equivalent to evennia.search_object)
+ copy_object
+
+ """
+
+ #
+ # ObjectManager Get methods
+ #
+
+ # account related
+
+
[docs]defget_object_with_account(self,ostring,exact=True,candidates=None):
+ """
+ Search for an object based on its account's name or dbref.
+
+ Args:
+ ostring (str or int): Search criterion or dbref. Searching
+ for an account is sometimes initiated by appending an `*` to
+ the beginning of the search criterion (e.g. in
+ local_and_global_search). This is stripped here.
+ exact (bool, optional): Require an exact account match.
+ candidates (list, optional): Only search among this list of possible
+ object candidates.
+
+ Return:
+ match (query): Matching query.
+
+ """
+ ostring=str(ostring).lstrip("*")
+ # simplest case - search by dbref
+ dbref=self.dbref(ostring)
+ ifdbref:
+ try:
+ returnself.get(db_account__id=dbref)
+ exceptself.model.DoesNotExist:
+ pass
+
+ # not a dbref. Search by name.
+ cand_restriction=(
+ candidatesisnotNone
+ andQ(pk__in=[_GA(obj,"id")forobjinmake_iter(candidates)ifobj])
+ orQ()
+ )
+ ifexact:
+ returnself.filter(cand_restriction&Q(db_account__username__iexact=ostring)).order_by(
+ "id"
+ )
+ else:# fuzzy matching
+ obj_cands=self.select_related().filter(
+ cand_restriction&Q(db_account__username__istartswith=ostring)
+ )
+ acct_cands=[obj.accountforobjinobj_cands]
+
+ ifobj_cands:
+ index_matches=string_partial_matching(
+ [acct.keyforacctinacct_cands],ostring,ret_index=True
+ )
+ acct_cands=[acct_cands[i].idforiinindex_matches]
+ returnobj_cands.filter(db_account__id__in=acct_cands).order_by("id")
+
+
[docs]defget_objs_with_key_and_typeclass(self,oname,otypeclass_path,candidates=None):
+ """
+ Returns objects based on simultaneous key and typeclass match.
+
+ Args:
+ oname (str): Object key to search for
+ otypeclass_path (str): Full Python path to tyepclass to search for
+ candidates (list, optional): Only match among the given list of candidates.
+
+ Returns:
+ matches (query): The matching objects.
+ """
+ cand_restriction=(
+ candidatesisnotNone
+ andQ(pk__in=[_GA(obj,"id")forobjinmake_iter(candidates)ifobj])
+ orQ()
+ )
+ returnself.filter(
+ cand_restriction&Q(db_key__iexact=oname,db_typeclass_path__exact=otypeclass_path)
+ ).order_by("id")
+
+ # attr/property related
+
+
[docs]defget_objs_with_attr(self,attribute_name,candidates=None):
+ """
+ Get objects based on having a certain Attribute defined.
+
+ Args:
+ attribute_name (str): Attribute name to search for.
+ candidates (list, optional): Only match among the given list of object
+ candidates.
+
+ Returns:
+ matches (query): All objects having the given attribute_name defined at all.
+
+ """
+ cand_restriction=(
+ candidatesisnotNoneandQ(id__in=[obj.idforobjincandidates])orQ()
+ )
+ returnself.filter(cand_restriction&Q(db_attributes__db_key=attribute_name)).order_by(
+ "id"
+ )
+
+
[docs]defget_objs_with_attr_value(
+ self,attribute_name,attribute_value,candidates=None,typeclasses=None
+ ):
+ """
+ Get all objects having the given attrname set to the given value.
+
+ Args:
+ attribute_name (str): Attribute key to search for.
+ attribute_value (any): Attribute value to search for. This can also be database objects.
+ candidates (list, optional): Candidate objects to limit search to.
+ typeclasses (list, optional): Python pats to restrict matches with.
+
+ Returns:
+ matches (query): Objects fullfilling both the `attribute_name` and
+ `attribute_value` criterions.
+
+ Notes:
+ This uses the Attribute's PickledField to transparently search the database by matching
+ the internal representation. This is reasonably effective but since Attribute values
+ cannot be indexed, searching by Attribute key is to be preferred whenever possible.
+
+ """
+ cand_restriction=(
+ candidatesisnotNone
+ andQ(pk__in=[_GA(obj,"id")forobjinmake_iter(candidates)ifobj])
+ orQ()
+ )
+ type_restriction=typeclassesandQ(db_typeclass_path__in=make_iter(typeclasses))orQ()
+
+ results=self.filter(
+ cand_restriction
+ &type_restriction
+ &Q(db_attributes__db_key=attribute_name)
+ &Q(db_attributes__db_value=attribute_value)
+ ).order_by("id")
+ returnresults
+
+
[docs]defget_objs_with_db_property(self,property_name,candidates=None):
+ """
+ Get all objects having a given db field property.
+
+ Args:
+ property_name (str): The name of the field to match for.
+ candidates (list, optional): Only search among th egiven candidates.
+
+ Returns:
+ matches (list): The found matches.
+
+ """
+ property_name="db_%s"%property_name.lstrip("db_")
+ cand_restriction=(
+ candidatesisnotNone
+ andQ(pk__in=[_GA(obj,"id")forobjinmake_iter(candidates)ifobj])
+ orQ()
+ )
+ querykwargs={property_name:None}
+ try:
+ returnlist(self.filter(cand_restriction).exclude(Q(**querykwargs)).order_by("id"))
+ exceptexceptions.FieldError:
+ return[]
+
+
[docs]defget_objs_with_db_property_value(
+ self,property_name,property_value,candidates=None,typeclasses=None
+ ):
+ """
+ Get objects with a specific field name and value.
+
+ Args:
+ property_name (str): Field name to search for.
+ property_value (any): Value required for field with `property_name` to have.
+ candidates (list, optional): List of objects to limit search to.
+ typeclasses (list, optional): List of typeclass-path strings to restrict matches with
+
+ """
+ ifisinstance(property_name,str):
+ ifnotproperty_name.startswith("db_"):
+ property_name="db_%s"%property_name
+ querykwargs={property_name:property_value}
+ cand_restriction=(
+ candidatesisnotNone
+ andQ(pk__in=[_GA(obj,"id")forobjinmake_iter(candidates)ifobj])
+ orQ()
+ )
+ type_restriction=typeclassesandQ(db_typeclass_path__in=make_iter(typeclasses))orQ()
+ try:
+ returnlist(
+ self.filter(cand_restriction&type_restriction&Q(**querykwargs)).order_by("id")
+ )
+ exceptexceptions.FieldError:
+ return[]
+ exceptValueError:
+ fromevennia.utilsimportlogger
+
+ logger.log_err(
+ "The property '%s' does not support search criteria of the type %s."
+ %(property_name,type(property_value))
+ )
+ return[]
+
+
[docs]defget_contents(self,location,excludeobj=None):
+ """
+ Get all objects that has a location set to this one.
+
+ Args:
+ location (Object): Where to get contents from.
+ excludeobj (Object or list, optional): One or more objects
+ to exclude from the match.
+
+ Returns:
+ contents (query): Matching contents, without excludeobj, if given.
+ """
+ exclude_restriction=(
+ Q(pk__in=[_GA(obj,"id")forobjinmake_iter(excludeobj)])ifexcludeobjelseQ()
+ )
+ returnself.filter(db_location=location).exclude(exclude_restriction).order_by("id")
+
+
[docs]defget_objs_with_key_or_alias(self,ostring,exact=True,candidates=None,typeclasses=None):
+ """
+ Args:
+ ostring (str): A search criterion.
+ exact (bool, optional): Require exact match of ostring
+ (still case-insensitive). If `False`, will do fuzzy matching
+ using `evennia.utils.utils.string_partial_matching` algorithm.
+ candidates (list): Only match among these candidates.
+ typeclasses (list): Only match objects with typeclasses having thess path strings.
+
+ Returns:
+ matches (query): A list of matches of length 0, 1 or more.
+ """
+ ifnotisinstance(ostring,str):
+ ifhasattr(ostring,"key"):
+ ostring=ostring.key
+ else:
+ return[]
+ ifis_iter(candidates)andnotlen(candidates):
+ # if candidates is an empty iterable there can be no matches
+ # Exit early.
+ return[]
+
+ # build query objects
+ candidates_id=[_GA(obj,"id")forobjinmake_iter(candidates)ifobj]
+ cand_restriction=candidatesisnotNoneandQ(pk__in=candidates_id)orQ()
+ type_restriction=typeclassesandQ(db_typeclass_path__in=make_iter(typeclasses))orQ()
+ ifexact:
+ # exact match - do direct search
+ return(
+ (
+ self.filter(
+ cand_restriction
+ &type_restriction
+ &(
+ Q(db_key__iexact=ostring)
+ |Q(db_tags__db_key__iexact=ostring)
+ &Q(db_tags__db_tagtype__iexact="alias")
+ )
+ )
+ )
+ .distinct()
+ .order_by("id")
+ )
+ elifcandidates:
+ # fuzzy with candidates
+ search_candidates=(
+ self.filter(cand_restriction&type_restriction).distinct().order_by("id")
+ )
+ else:
+ # fuzzy without supplied candidates - we select our own candidates
+ search_candidates=(
+ self.filter(
+ type_restriction
+ &(Q(db_key__istartswith=ostring)|Q(db_tags__db_key__istartswith=ostring))
+ )
+ .distinct()
+ .order_by("id")
+ )
+ # fuzzy matching
+ key_strings=search_candidates.values_list("db_key",flat=True).order_by("id")
+
+ index_matches=string_partial_matching(key_strings,ostring,ret_index=True)
+ ifindex_matches:
+ # a match by key
+ return[objforind,objinenumerate(search_candidates)ifindinindex_matches]
+ else:
+ # match by alias rather than by key
+ search_candidates=search_candidates.filter(
+ db_tags__db_tagtype__iexact="alias",db_tags__db_key__icontains=ostring
+ ).distinct()
+ alias_strings=[]
+ alias_candidates=[]
+ # TODO create the alias_strings and alias_candidates lists more efficiently?
+ forcandidateinsearch_candidates:
+ foraliasincandidate.aliases.all():
+ alias_strings.append(alias)
+ alias_candidates.append(candidate)
+ index_matches=string_partial_matching(alias_strings,ostring,ret_index=True)
+ ifindex_matches:
+ # it's possible to have multiple matches to the same Object, we must weed those out
+ returnlist({alias_candidates[ind]forindinindex_matches})
+ return[]
+
+ # main search methods and helper functions
+
+
[docs]defsearch_object(
+ self,
+ searchdata,
+ attribute_name=None,
+ typeclass=None,
+ candidates=None,
+ exact=True,
+ use_dbref=True,
+ ):
+ """
+ Search as an object globally or in a list of candidates and
+ return results. The result is always an Object. Always returns
+ a list.
+
+ Args:
+ searchdata (str or Object): The entity to match for. This is
+ usually a key string but may also be an object itself.
+ By default (if no `attribute_name` is set), this will
+ search `object.key` and `object.aliases` in order.
+ Can also be on the form #dbref, which will (if
+ `exact=True`) be matched against primary key.
+ attribute_name (str): Use this named Attribute to
+ match searchdata against, instead of the defaults. If
+ this is the name of a database field (with or without
+ the `db_` prefix), that will be matched too.
+ typeclass (str or TypeClass): restrict matches to objects
+ having this typeclass. This will help speed up global
+ searches.
+ candidates (list): If supplied, search will
+ only be performed among the candidates in this list. A
+ common list of candidates is the contents of the
+ current location searched.
+ exact (bool): Match names/aliases exactly or partially.
+ Partial matching matches the beginning of words in the
+ names/aliases, using a matching routine to separate
+ multiple matches in names with multiple components (so
+ "bi sw" will match "Big sword"). Since this is more
+ expensive than exact matching, it is recommended to be
+ used together with the `candidates` keyword to limit the
+ number of possibilities. This value has no meaning if
+ searching for attributes/properties.
+ use_dbref (bool): If False, bypass direct lookup of a string
+ on the form #dbref and treat it like any string.
+
+ Returns:
+ matches (list): Matching objects
+
+ """
+
+ def_searcher(searchdata,candidates,typeclass,exact=False):
+ """
+ Helper method for searching objects. `typeclass` is only used
+ for global searching (no candidates)
+ """
+ ifattribute_name:
+ # attribute/property search (always exact).
+ matches=self.get_objs_with_db_property_value(
+ attribute_name,searchdata,candidates=candidates,typeclasses=typeclass
+ )
+ ifmatches:
+ returnmatches
+ returnself.get_objs_with_attr_value(
+ attribute_name,searchdata,candidates=candidates,typeclasses=typeclass
+ )
+ else:
+ # normal key/alias search
+ returnself.get_objs_with_key_or_alias(
+ searchdata,exact=exact,candidates=candidates,typeclasses=typeclass
+ )
+
+ ifnotsearchdataandsearchdata!=0:
+ return[]
+
+ iftypeclass:
+ # typeclass may also be a list
+ typeclasses=make_iter(typeclass)
+ fori,typeclassinenumerate(make_iter(typeclasses)):
+ ifcallable(typeclass):
+ typeclasses[i]="%s.%s"%(typeclass.__module__,typeclass.__name__)
+ else:
+ typeclasses[i]="%s"%typeclass
+ typeclass=typeclasses
+
+ ifcandidatesisnotNone:
+ ifnotcandidates:
+ # candidates is the empty list. This should mean no matches can ever be acquired.
+ return[]
+ # Convenience check to make sure candidates are really dbobjs
+ candidates=[candforcandinmake_iter(candidates)ifcand]
+ iftypeclass:
+ candidates=[
+ candforcandincandidatesif_GA(cand,"db_typeclass_path")intypeclass
+ ]
+
+ dbref=notattribute_nameandexactanduse_dbrefandself.dbref(searchdata)
+ ifdbref:
+ # Easiest case - dbref matching (always exact)
+ dbref_match=self.dbref_search(dbref)
+ ifdbref_match:
+ ifnotcandidatesordbref_matchincandidates:
+ return[dbref_match]
+ else:
+ return[]
+
+ # Search through all possibilities.
+ match_number=None
+ # always run first check exact - we don't want partial matches
+ # if on the form of 1-keyword etc.
+ matches=_searcher(searchdata,candidates,typeclass,exact=True)
+ ifnotmatches:
+ # no matches found - check if we are dealing with N-keyword
+ # query - if so, strip it.
+ match=_MULTIMATCH_REGEX.match(str(searchdata))
+ match_number=None
+ ifmatch:
+ # strips the number
+ match_number,searchdata=match.group("number"),match.group("name")
+ match_number=int(match_number)-1
+ ifmatch_numberisnotNoneornotexact:
+ # run search again, with the exactness set by call
+ matches=_searcher(searchdata,candidates,typeclass,exact=exact)
+
+ # deal with result
+ iflen(matches)==1andmatch_numberisnotNoneandmatch_number!=0:
+ # this indicates trying to get a single match with a match-number
+ # targeting some higher-number match (like 2-box when there is only
+ # one box in the room). This leads to a no-match.
+ matches=[]
+ eliflen(matches)>1andmatch_numberisnotNone:
+ # multiple matches, but a number was given to separate them
+ if0<=match_number<len(matches):
+ # limit to one match
+ matches=[matches[match_number]]
+ else:
+ # a number was given outside of range. This means a no-match.
+ matches=[]
+
+ # return a list (possibly empty)
+ returnmatches
[docs]defcopy_object(
+ self,
+ original_object,
+ new_key=None,
+ new_location=None,
+ new_home=None,
+ new_permissions=None,
+ new_locks=None,
+ new_aliases=None,
+ new_destination=None,
+ ):
+ """
+ Create and return a new object as a copy of the original object. All
+ will be identical to the original except for the arguments given
+ specifically to this method. Object contents will not be copied.
+
+ Args:
+ original_object (Object): The object to make a copy from.
+ new_key (str, optional): Name of the copy, if different
+ from the original.
+ new_location (Object, optional): Alternate location.
+ new_home (Object, optional): Change the home location
+ new_aliases (list, optional): Give alternate object
+ aliases as a list of strings.
+ new_destination (Object, optional): Used only by exits.
+
+ Returns:
+ copy (Object or None): The copy of `original_object`,
+ optionally modified as per the ingoing keyword
+ arguments. `None` if an error was encountered.
+
+ """
+
+ # get all the object's stats
+ typeclass_path=original_object.typeclass_path
+ ifnotnew_key:
+ new_key=original_object.key
+ ifnotnew_location:
+ new_location=original_object.location
+ ifnotnew_home:
+ new_home=original_object.home
+ ifnotnew_aliases:
+ new_aliases=original_object.aliases.all()
+ ifnotnew_locks:
+ new_locks=original_object.db_lock_storage
+ ifnotnew_permissions:
+ new_permissions=original_object.permissions.all()
+ ifnotnew_destination:
+ new_destination=original_object.destination
+
+ # create new object
+ fromevennia.utilsimportcreate
+ fromevennia.scripts.modelsimportScriptDB
+
+ new_object=create.create_object(
+ typeclass_path,
+ key=new_key,
+ location=new_location,
+ home=new_home,
+ permissions=new_permissions,
+ locks=new_locks,
+ aliases=new_aliases,
+ destination=new_destination,
+ )
+ ifnotnew_object:
+ returnNone
+
+ # copy over all attributes from old to new.
+ attrs=(
+ (a.key,a.value,a.category,a.lock_storage)forainoriginal_object.attributes.all()
+ )
+ new_object.attributes.batch_add(*attrs)
+
+ # copy over all cmdsets, if any
+ foricmdset,cmdsetinenumerate(original_object.cmdset.all()):
+ ificmdset==0:
+ new_object.cmdset.add_default(cmdset)
+ else:
+ new_object.cmdset.add(cmdset)
+
+ # copy over all scripts, if any
+ forscriptinoriginal_object.scripts.all():
+ ScriptDB.objects.copy_script(script,new_obj=new_object)
+
+ # copy over all tags, if any
+ tags=(
+ (t.db_key,t.db_category,t.db_data)fortinoriginal_object.tags.all(return_objs=True)
+ )
+ new_object.tags.batch_add(*tags)
+
+ returnnew_object
+
+
[docs]defclear_all_sessids(self):
+ """
+ Clear the db_sessid field of all objects having also the
+ db_account field set.
+ """
+ self.filter(db_sessid__isnull=False).update(db_sessid=None)
+"""
+This module defines the database models for all in-game objects, that
+is, all objects that has an actual existence in-game.
+
+Each database object is 'decorated' with a 'typeclass', a normal
+python class that implements all the various logics needed by the game
+in question. Objects created of this class transparently communicate
+with its related database object for storing all attributes. The
+admin should usually not have to deal directly with this database
+object layer.
+
+Attributes are separate objects that store values persistently onto
+the database object. Like everything else, they can be accessed
+transparently through the decorating TypeClass.
+"""
+fromdjango.confimportsettings
+fromdjango.dbimportmodels
+fromdjango.core.exceptionsimportObjectDoesNotExist
+fromdjango.core.validatorsimportvalidate_comma_separated_integer_list
+
+fromevennia.typeclasses.modelsimportTypedObject
+fromevennia.objects.managerimportObjectDBManager
+fromevennia.utilsimportlogger
+fromevennia.utils.utilsimportmake_iter,dbref,lazy_property
+
+
+
[docs]classContentsHandler:
+ """
+ Handles and caches the contents of an object to avoid excessive
+ lookups (this is done very often due to cmdhandler needing to look
+ for object-cmdsets). It is stored on the 'contents_cache' property
+ of the ObjectDB.
+ """
+
+
[docs]def__init__(self,obj):
+ """
+ Sets up the contents handler.
+
+ Args:
+ obj (Object): The object on which the
+ handler is defined
+
+ """
+ self.obj=obj
+ self._pkcache={}
+ self._idcache=obj.__class__.__instance_cache__
+ self.init()
[docs]defget(self,exclude=None):
+ """
+ Return the contents of the cache.
+
+ Args:
+ exclude (Object or list of Object): object(s) to ignore
+
+ Returns:
+ objects (list): the Objects inside this location
+
+ """
+ ifexclude:
+ pks=[pkforpkinself._pkcacheifpknotin[excl.pkforexclinmake_iter(exclude)]]
+ else:
+ pks=self._pkcache
+ try:
+ return[self._idcache[pk]forpkinpks]
+ exceptKeyError:
+ # this can happen if the idmapper cache was cleared for an object
+ # in the contents cache. If so we need to re-initialize and try again.
+ self.init()
+ try:
+ return[self._idcache[pk]forpkinpks]
+ exceptKeyError:
+ # this means the central instance_cache was totally flushed.
+ # Re-fetching from database will rebuild the necessary parts of the cache
+ # for next fetch.
+ returnlist(ObjectDB.objects.filter(db_location=self.obj))
+
+
[docs]defadd(self,obj):
+ """
+ Add a new object to this location
+
+ Args:
+ obj (Object): object to add
+
+ """
+ self._pkcache[obj.pk]=None
+
+
[docs]defremove(self,obj):
+ """
+ Remove object from this location
+
+ Args:
+ obj (Object): object to remove
+
+ """
+ self._pkcache.pop(obj.pk,None)
+
+
[docs]defclear(self):
+ """
+ Clear the contents cache and re-initialize
+
+ """
+ self._pkcache={}
+ self.init()
[docs]classObjectDB(TypedObject):
+ """
+ All objects in the game use the ObjectDB model to store
+ data in the database. This is handled transparently through
+ the typeclass system.
+
+ Note that the base objectdb is very simple, with
+ few defined fields. Use attributes to extend your
+ type class with new database-stored variables.
+
+ The TypedObject supplies the following (inherited) properties:
+
+ - key - main name
+ - name - alias for key
+ - db_typeclass_path - the path to the decorating typeclass
+ - db_date_created - time stamp of object creation
+ - permissions - perm strings
+ - locks - lock definitions (handler)
+ - dbref - #id of object
+ - db - persistent attribute storage
+ - ndb - non-persistent attribute storage
+
+ The ObjectDB adds the following properties:
+
+ - account - optional connected account (always together with sessid)
+ - sessid - optional connection session id (always together with account)
+ - location - in-game location of object
+ - home - safety location for object (handler)
+ - scripts - scripts assigned to object (handler from typeclass)
+ - cmdset - active cmdset on object (handler from typeclass)
+ - aliases - aliases for this object (property)
+ - nicks - nicknames for *other* things in Evennia (handler)
+ - sessions - sessions connected to this object (see also account)
+ - has_account - bool if an active account is currently connected
+ - contents - other objects having this object as location
+ - exits - exits from this object
+
+ """
+
+ #
+ # ObjectDB Database model setup
+ #
+ #
+ # inherited fields (from TypedObject):
+ # db_key (also 'name' works), db_typeclass_path, db_date_created,
+ # db_permissions
+ #
+ # These databse fields (including the inherited ones) should normally be
+ # managed by their corresponding wrapper properties, named same as the
+ # field, but without the db_* prefix (e.g. the db_key field is set with
+ # self.key instead). The wrappers are created at the metaclass level and
+ # will automatically save and cache the data more efficiently.
+
+ # If this is a character object, the account is connected here.
+ db_account=models.ForeignKey(
+ "accounts.AccountDB",
+ null=True,
+ verbose_name="account",
+ on_delete=models.SET_NULL,
+ help_text="an Account connected to this object, if any.",
+ )
+
+ # the session id associated with this account, if any
+ db_sessid=models.CharField(
+ null=True,
+ max_length=32,
+ validators=[validate_comma_separated_integer_list],
+ verbose_name="session id",
+ help_text="csv list of session ids of connected Account, if any.",
+ )
+ # The location in the game world. Since this one is likely
+ # to change often, we set this with the 'location' property
+ # to transparently handle Typeclassing.
+ db_location=models.ForeignKey(
+ "self",
+ related_name="locations_set",
+ db_index=True,
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ verbose_name="game location",
+ )
+ # a safety location, this usually don't change much.
+ db_home=models.ForeignKey(
+ "self",
+ related_name="homes_set",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ verbose_name="home location",
+ )
+ # destination of this object - primarily used by exits.
+ db_destination=models.ForeignKey(
+ "self",
+ related_name="destinations_set",
+ db_index=True,
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ verbose_name="destination",
+ help_text="a destination, used only by exit objects.",
+ )
+ # database storage of persistant cmdsets.
+ db_cmdset_storage=models.CharField(
+ "cmdset",
+ max_length=255,
+ null=True,
+ blank=True,
+ help_text="optional python path to a cmdset class.",
+ )
+
+ # Database manager
+ objects=ObjectDBManager()
+
+ # defaults
+ __settingsclasspath__=settings.BASE_OBJECT_TYPECLASS
+ __defaultclasspath__="evennia.objects.objects.DefaultObject"
+ __applabel__="objects"
+
+
+
+ # cmdset_storage property handling
+ def__cmdset_storage_get(self):
+ """getter"""
+ storage=self.db_cmdset_storage
+ return[path.strip()forpathinstorage.split(",")]ifstorageelse[]
+
+ def__cmdset_storage_set(self,value):
+ """setter"""
+ self.db_cmdset_storage=",".join(str(val).strip()forvalinmake_iter(value))
+ self.save(update_fields=["db_cmdset_storage"])
+
+ def__cmdset_storage_del(self):
+ """deleter"""
+ self.db_cmdset_storage=None
+ self.save(update_fields=["db_cmdset_storage"])
+
+ cmdset_storage=property(__cmdset_storage_get,__cmdset_storage_set,__cmdset_storage_del)
+
+ # location getsetter
+ def__location_get(self):
+ """Get location"""
+ returnself.db_location
+
+ def__location_set(self,location):
+ """Set location, checking for loops and allowing dbref"""
+ ifisinstance(location,(str,int)):
+ # allow setting of #dbref
+ dbid=dbref(location,reqhash=False)
+ ifdbid:
+ try:
+ location=ObjectDB.objects.get(id=dbid)
+ exceptObjectDoesNotExist:
+ # maybe it is just a name that happens to look like a dbid
+ pass
+ try:
+
+ defis_loc_loop(loc,depth=0):
+ """Recursively traverse target location, trying to catch a loop."""
+ ifdepth>10:
+ returnNone
+ elifloc==self:
+ raiseRuntimeError
+ eliflocisNone:
+ raiseRuntimeWarning
+ returnis_loc_loop(loc.db_location,depth+1)
+
+ try:
+ is_loc_loop(location)
+ exceptRuntimeWarning:
+ # we caught an infinite location loop!
+ # (location1 is in location2 which is in location1 ...)
+ pass
+
+ # if we get to this point we are ready to change location
+
+ old_location=self.db_location
+
+ # this is checked in _db_db_location_post_save below
+ self._safe_contents_update=True
+
+ # actually set the field (this will error if location is invalid)
+ self.db_location=location
+ self.save(update_fields=["db_location"])
+
+ # remove the safe flag
+ delself._safe_contents_update
+
+ # update the contents cache
+ ifold_location:
+ old_location.contents_cache.remove(self)
+ ifself.db_location:
+ self.db_location.contents_cache.add(self)
+
+ exceptRuntimeError:
+ errmsg="Error: %s.location = %s creates a location loop."%(self.key,location)
+ raiseRuntimeError(errmsg)
+ exceptExceptionase:
+ errmsg="Error (%s): %s is not a valid location."%(str(e),location)
+ raiseRuntimeError(errmsg)
+ return
+
+ def__location_del(self):
+ """Cleanly delete the location reference"""
+ self.db_location=None
+ self.save(update_fields=["db_location"])
+
+ location=property(__location_get,__location_set,__location_del)
+
+
[docs]defat_db_location_postsave(self,new):
+ """
+ This is called automatically after the location field was
+ saved, no matter how. It checks for a variable
+ _safe_contents_update to know if the save was triggered via
+ the location handler (which updates the contents cache) or
+ not.
+
+ Args:
+ new (bool): Set if this location has not yet been saved before.
+
+ """
+ ifnothasattr(self,"_safe_contents_update"):
+ # changed/set outside of the location handler
+ ifnew:
+ # if new, there is no previous location to worry about
+ ifself.db_location:
+ self.db_location.contents_cache.add(self)
+ else:
+ # Since we cannot know at this point was old_location was, we
+ # trigger a full-on contents_cache update here.
+ logger.log_warn(
+ "db_location direct save triggered contents_cache.init() for all objects!"
+ )
+ [o.contents_cache.init()foroinself.__dbclass__.get_all_cached_instances()]
+"""
+This module defines the basic `DefaultObject` and its children
+`DefaultCharacter`, `DefaultAccount`, `DefaultRoom` and `DefaultExit`.
+These are the (default) starting points for all in-game visible
+entities.
+
+"""
+importtime
+importinflect
+fromcollectionsimportdefaultdict
+
+fromdjango.confimportsettings
+
+fromevennia.typeclasses.modelsimportTypeclassBase
+fromevennia.typeclasses.attributesimportNickHandler
+fromevennia.objects.managerimportObjectManager
+fromevennia.objects.modelsimportObjectDB
+fromevennia.scripts.scripthandlerimportScriptHandler
+fromevennia.commandsimportcmdset,command
+fromevennia.commands.cmdsethandlerimportCmdSetHandler
+fromevennia.utilsimportcreate
+fromevennia.utilsimportsearch
+fromevennia.utilsimportlogger
+fromevennia.utilsimportansi
+fromevennia.utils.utilsimport(
+ class_from_module,
+ variable_from_module,
+ lazy_property,
+ make_iter,
+ is_iter,
+ list_to_string,
+ to_str,
+)
+fromdjango.utils.translationimportgettextas_
+
+_INFLECT=inflect.engine()
+_MULTISESSION_MODE=settings.MULTISESSION_MODE
+
+_ScriptDB=None
+_SESSIONS=None
+_CMDHANDLER=None
+
+_AT_SEARCH_RESULT=variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".",1))
+_COMMAND_DEFAULT_CLASS=class_from_module(settings.COMMAND_DEFAULT_CLASS)
+# the sessid_max is based on the length of the db_sessid csv field (excluding commas)
+_SESSID_MAX=16if_MULTISESSION_MODEin(1,3)else1
+
+
+
[docs]classObjectSessionHandler(object):
+ """
+ Handles the get/setting of the sessid
+ comma-separated integer field
+ """
+
+
[docs]def__init__(self,obj):
+ """
+ Initializes the handler.
+
+ Args:
+ obj (Object): The object on which the handler is defined.
+
+ """
+ self.obj=obj
+ self._sessid_cache=[]
+ self._recache()
+
+ def_recache(self):
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+ self._sessid_cache=list(
+ set(int(val)forvalin(self.obj.db_sessidor"").split(",")ifval)
+ )
+ ifany(sessidforsessidinself._sessid_cacheifsessidnotin_SESSIONS):
+ # cache is out of sync with sessionhandler! Only retain the ones in the handler.
+ self._sessid_cache=[sessidforsessidinself._sessid_cacheifsessidin_SESSIONS]
+ self.obj.db_sessid=",".join(str(val)forvalinself._sessid_cache)
+ self.obj.save(update_fields=["db_sessid"])
+
+
[docs]defget(self,sessid=None):
+ """
+ Get the sessions linked to this Object.
+
+ Args:
+ sessid (int, optional): A specific session id.
+
+ Returns:
+ sessions (list): The sessions connected to this object. If `sessid` is given,
+ this is a list of one (or zero) elements.
+
+ Notes:
+ Aliased to `self.all()`.
+
+ """
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+ ifsessid:
+ sessions=(
+ [_SESSIONS[sessid]ifsessidin_SESSIONSelseNone]
+ ifsessidinself._sessid_cache
+ else[]
+ )
+ else:
+ sessions=[
+ _SESSIONS[ssid]ifssidin_SESSIONSelseNoneforssidinself._sessid_cache
+ ]
+ ifNoneinsessions:
+ # this happens only if our cache has gone out of sync with the SessionHandler.
+ self._recache()
+ returnself.get(sessid=sessid)
+ returnsessions
+
+
[docs]defall(self):
+ """
+ Alias to get(), returning all sessions.
+
+ Returns:
+ sessions (list): All sessions.
+
+ """
+ returnself.get()
+
+
[docs]defadd(self,session):
+ """
+ Add session to handler.
+
+ Args:
+ session (Session or int): Session or session id to add.
+
+ Notes:
+ We will only add a session/sessid if this actually also exists
+ in the the core sessionhandler.
+
+ """
+ global_SESSIONS
+ ifnot_SESSIONS:
+ fromevennia.server.sessionhandlerimportSESSIONSas_SESSIONS
+ try:
+ sessid=session.sessid
+ exceptAttributeError:
+ sessid=session
+
+ sessid_cache=self._sessid_cache
+ ifsessidin_SESSIONSandsessidnotinsessid_cache:
+ iflen(sessid_cache)>=_SESSID_MAX:
+ return
+ sessid_cache.append(sessid)
+ self.obj.db_sessid=",".join(str(val)forvalinsessid_cache)
+ self.obj.save(update_fields=["db_sessid"])
+
+
[docs]defremove(self,session):
+ """
+ Remove session from handler.
+
+ Args:
+ session (Session or int): Session or session id to remove.
+
+ """
+ try:
+ sessid=session.sessid
+ exceptAttributeError:
+ sessid=session
+
+ sessid_cache=self._sessid_cache
+ ifsessidinsessid_cache:
+ sessid_cache.remove(sessid)
+ self.obj.db_sessid=",".join(str(val)forvalinsessid_cache)
+ self.obj.save(update_fields=["db_sessid"])
[docs]defcount(self):
+ """
+ Get amount of sessions connected.
+
+ Returns:
+ sesslen (int): Number of sessions handled.
+
+ """
+ returnlen(self._sessid_cache)
+
+
+#
+# Base class to inherit from.
+
+
+
[docs]classDefaultObject(ObjectDB,metaclass=TypeclassBase):
+ """
+ This is the root typeclass object, representing all entities that
+ have an actual presence in-game. DefaultObjects generally have a
+ location. They can also be manipulated and looked at. Game
+ entities you define should inherit from DefaultObject at some distance.
+
+ It is recommended to create children of this class using the
+ `evennia.create_object()` function rather than to initialize the class
+ directly - this will both set things up and efficiently save the object
+ without `obj.save()` having to be called explicitly.
+
+ """
+
+ # lockstring of newly created objects, for easy overloading.
+ # Will be formatted with the appropriate attributes.
+ lockstring="control:id({account_id}) or perm(Admin);delete:id({account_id}) or perm(Admin)"
+
+ objects=ObjectManager()
+
+ # on-object properties
+
+
+
+ @property
+ defis_connected(self):
+ # we get an error for objects subscribed to channels without this
+ ifself.account:# seems sane to pass on the account
+ returnself.account.is_connected
+ else:
+ returnFalse
+
+ @property
+ defhas_account(self):
+ """
+ Convenience property for checking if an active account is
+ currently connected to this object.
+
+ """
+ returnself.sessions.count()
+
+ @property
+ defis_superuser(self):
+ """
+ Check if user has an account, and if so, if it is a superuser.
+
+ """
+ return(
+ self.db_account
+ andself.db_account.is_superuser
+ andnotself.db_account.attributes.get("_quell")
+ )
+
+
[docs]defcontents_get(self,exclude=None):
+ """
+ Returns the contents of this object, i.e. all
+ objects that has this object set as its location.
+ This should be publically available.
+
+ Args:
+ exclude (Object): Object to exclude from returned
+ contents list
+
+ Returns:
+ contents (list): List of contents of this Object.
+
+ Notes:
+ Also available as the `contents` property.
+
+ """
+ con=self.contents_cache.get(exclude=exclude)
+ # print "contents_get:", self, con, id(self), calledby() # DEBUG
+ returncon
+
+
[docs]defcontents_set(self,*args):
+ "You cannot replace this property"
+ raiseAttributeError(
+ "{}.contents is read-only. Use obj.move_to or "
+ "obj.location to move an object here.".format(self.__class__)
+ )
+
+ contents=property(contents_get,contents_set,contents_set)
+
+ @property
+ defexits(self):
+ """
+ Returns all exits from this object, i.e. all objects at this
+ location having the property destination != `None`.
+ """
+ return[exiforexiinself.contentsifexi.destination]
+
+ # main methods
+
+
[docs]defget_display_name(self,looker,**kwargs):
+ """
+ Displays the name of the object in a viewer-aware manner.
+
+ Args:
+ looker (TypedObject): The object or account that is looking
+ at/getting inforamtion for this object.
+
+ Returns:
+ name (str): A string containing the name of the object,
+ including the DBREF if this user is privileged to control
+ said object.
+
+ Notes:
+ This function could be extended to change how object names
+ appear to users in character, but be wary. This function
+ does not change an object's keys or aliases when
+ searching, and is expected to produce something useful for
+ builders.
+
+ """
+ ifself.locks.check_lockstring(looker,"perm(Builder)"):
+ return"{}(#{})".format(self.name,self.id)
+ returnself.name
+
+
[docs]defget_numbered_name(self,count,looker,**kwargs):
+ """
+ Return the numbered (singular, plural) forms of this object's key. This is by default called
+ by return_appearance and is used for grouping multiple same-named of this object. Note that
+ this will be called on *every* member of a group even though the plural name will be only
+ shown once. Also the singular display version, such as 'an apple', 'a tree' is determined
+ from this method.
+
+ Args:
+ count (int): Number of objects of this type
+ looker (Object): Onlooker. Not used by default.
+ Keyword Args:
+ key (str): Optional key to pluralize, if given, use this instead of the object's key.
+ Returns:
+ singular (str): The singular form to display.
+ plural (str): The determined plural form of the key, including the count.
+ """
+ plural_category="plural_key"
+ key=kwargs.get("key",self.key)
+ key=ansi.ANSIString(key)# this is needed to allow inflection of colored names
+ try:
+ plural=_INFLECT.plural(key,count)
+ plural="{}{}".format(_INFLECT.number_to_words(count,threshold=12),plural)
+ exceptIndexError:
+ # this is raised by inflect if the input is not a proper noun
+ plural=key
+ singular=_INFLECT.an(key)
+ ifnotself.aliases.get(plural,category=plural_category):
+ # we need to wipe any old plurals/an/a in case key changed in the interrim
+ self.aliases.clear(category=plural_category)
+ self.aliases.add(plural,category=plural_category)
+ # save the singular form as an alias here too so we can display "an egg" and also
+ # look at 'an egg'.
+ self.aliases.add(singular,category=plural_category)
+ returnsingular,plural
+
+
[docs]defsearch(
+ self,
+ searchdata,
+ global_search=False,
+ use_nicks=True,
+ typeclass=None,
+ location=None,
+ attribute_name=None,
+ quiet=False,
+ exact=False,
+ candidates=None,
+ nofound_string=None,
+ multimatch_string=None,
+ use_dbref=None,
+ ):
+ """
+ Returns an Object matching a search string/condition
+
+ Perform a standard object search in the database, handling
+ multiple results and lack thereof gracefully. By default, only
+ objects in the current `location` of `self` or its inventory are searched for.
+
+ Args:
+ searchdata (str or obj): Primary search criterion. Will be matched
+ against `object.key` (with `object.aliases` second) unless
+ the keyword attribute_name specifies otherwise.
+
+ Special strings:
+
+ - `#<num>`: search by unique dbref. This is always
+ a global search.
+ - `me,self`: self-reference to this object
+ - `<num>-<string>` - can be used to differentiate
+ between multiple same-named matches. The exact form of this input
+ is given by `settings.SEARCH_MULTIMATCH_REGEX`.
+
+ global_search (bool): Search all objects globally. This overrules 'location' data.
+ use_nicks (bool): Use nickname-replace (nicktype "object") on `searchdata`.
+ typeclass (str or Typeclass, or list of either): Limit search only
+ to `Objects` with this typeclass. May be a list of typeclasses
+ for a broader search.
+ location (Object or list): Specify a location or multiple locations
+ to search. Note that this is used to query the *contents* of a
+ location and will not match for the location itself -
+ if you want that, don't set this or use `candidates` to specify
+ exactly which objects should be searched.
+ attribute_name (str): Define which property to search. If set, no
+ key+alias search will be performed. This can be used
+ to search database fields (db_ will be automatically
+ prepended), and if that fails, it will try to return
+ objects having Attributes with this name and value
+ equal to searchdata. A special use is to search for
+ "key" here if you want to do a key-search without
+ including aliases.
+ quiet (bool): don't display default error messages - this tells the
+ search method that the user wants to handle all errors
+ themselves. It also changes the return value type, see
+ below.
+ exact (bool): if unset (default) - prefers to match to beginning of
+ string rather than not matching at all. If set, requires
+ exact matching of entire string.
+ candidates (list of objects): this is an optional custom list of objects
+ to search (filter) between. It is ignored if `global_search`
+ is given. If not set, this list will automatically be defined
+ to include the location, the contents of location and the
+ caller's contents (inventory).
+ nofound_string (str): optional custom string for not-found error message.
+ multimatch_string (str): optional custom string for multimatch error header.
+ use_dbref (bool or None, optional): If `True`, allow to enter e.g. a query "#123"
+ to find an object (globally) by its database-id 123. If `False`, the string "#123"
+ will be treated like a normal string. If `None` (default), the ability to query by
+ #dbref is turned on if `self` has the permission 'Builder' and is turned off
+ otherwise.
+
+ Returns:
+ Object, None or list: Will return an `Object` or `None` if `quiet=False`. Will return a
+ list with 0, 1 or more matches if `quiet=True`. If `stacked` is a positive integer, this
+ list may contain all stacked identical matches.
+
+ Notes:
+ To find Accounts, use eg. `evennia.account_search`. If
+ `quiet=False`, error messages will be handled by
+ `settings.SEARCH_AT_RESULT` and echoed automatically (on
+ error, return will be `None`). If `quiet=True`, the error
+ messaging is assumed to be handled by the caller.
+
+ """
+ is_string=isinstance(searchdata,str)
+
+ ifis_string:
+ # searchdata is a string; wrap some common self-references
+ ifsearchdata.lower()in("here",):
+ return[self.location]ifquietelseself.location
+ ifsearchdata.lower()in("me","self"):
+ return[self]ifquietelseself
+
+ ifuse_dbrefisNone:
+ use_dbref=self.locks.check_lockstring(self,"_dummy:perm(Builder)")
+
+ ifuse_nicks:
+ # do nick-replacement on search
+ searchdata=self.nicks.nickreplace(
+ searchdata,categories=("object","account"),include_account=True
+ )
+
+ ifglobal_searchor(
+ is_string
+ andsearchdata.startswith("#")
+ andlen(searchdata)>1
+ andsearchdata[1:].isdigit()
+ ):
+ # only allow exact matching if searching the entire database
+ # or unique #dbrefs
+ exact=True
+ candidates=None
+
+ elifcandidatesisNone:
+ # no custom candidates given - get them automatically
+ iflocation:
+ # location(s) were given
+ candidates=[]
+ forobjinmake_iter(location):
+ candidates.extend(obj.contents)
+ else:
+ # local search. Candidates are taken from
+ # self.contents, self.location and
+ # self.location.contents
+ location=self.location
+ candidates=self.contents
+ iflocation:
+ candidates=candidates+[location]+location.contents
+ else:
+ # normally we don't need this since we are
+ # included in location.contents
+ candidates.append(self)
+
+ results=ObjectDB.objects.object_search(
+ searchdata,
+ attribute_name=attribute_name,
+ typeclass=typeclass,
+ candidates=candidates,
+ exact=exact,
+ use_dbref=use_dbref,
+ )
+
+ ifquiet:
+ returnlist(results)
+ return_AT_SEARCH_RESULT(
+ results,
+ self,
+ query=searchdata,
+ nofound_string=nofound_string,
+ multimatch_string=multimatch_string,
+ )
+
+
[docs]defsearch_account(self,searchdata,quiet=False):
+ """
+ Simple shortcut wrapper to search for accounts, not characters.
+
+ Args:
+ searchdata (str): Search criterion - the key or dbref of the account
+ to search for. If this is "here" or "me", search
+ for the account connected to this object.
+ quiet (bool): Returns the results as a list rather than
+ echo eventual standard error messages. Default `False`.
+
+ Returns:
+ result (Account, None or list): Just what is returned depends on
+ the `quiet` setting:
+ - `quiet=True`: No match or multumatch auto-echoes errors
+ to self.msg, then returns `None`. The esults are passed
+ through `settings.SEARCH_AT_RESULT` and
+ `settings.SEARCH_AT_MULTIMATCH_INPUT`. If there is a
+ unique match, this will be returned.
+ - `quiet=True`: No automatic error messaging is done, and
+ what is returned is always a list with 0, 1 or more
+ matching Accounts.
+
+ """
+ ifisinstance(searchdata,str):
+ # searchdata is a string; wrap some common self-references
+ ifsearchdata.lower()in("me","self"):
+ return[self.account]ifquietelseself.account
+
+ results=search.search_account(searchdata)
+
+ ifquiet:
+ returnresults
+ return_AT_SEARCH_RESULT(results,self,query=searchdata)
+
+
[docs]defexecute_cmd(self,raw_string,session=None,**kwargs):
+ """
+ Do something as this object. This is never called normally,
+ it's only used when wanting specifically to let an object be
+ the caller of a command. It makes use of nicks of eventual
+ connected accounts as well.
+
+ Args:
+ raw_string (string): Raw command input
+ session (Session, optional): Session to
+ return results to
+
+ Keyword Args:
+ Other keyword arguments will be added to the found command
+ object instace as variables before it executes. This is
+ unused by default Evennia but may be used to set flags and
+ change operating paramaters for commands at run-time.
+
+ Returns:
+ defer (Deferred): This is an asynchronous Twisted object that
+ will not fire until the command has actually finished
+ executing. To overload this one needs to attach
+ callback functions to it, with addCallback(function).
+ This function will be called with an eventual return
+ value from the command execution. This return is not
+ used at all by Evennia by default, but might be useful
+ for coders intending to implement some sort of nested
+ command structure.
+
+ """
+ # break circular import issues
+ global_CMDHANDLER
+ ifnot_CMDHANDLER:
+ fromevennia.commands.cmdhandlerimportcmdhandleras_CMDHANDLER
+
+ # nick replacement - we require full-word matching.
+ # do text encoding conversion
+ raw_string=self.nicks.nickreplace(
+ raw_string,categories=("inputline","channel"),include_account=True
+ )
+ return_CMDHANDLER(
+ self,raw_string,callertype="object",session=session,**kwargs
+ )
+
+
[docs]defmsg(self,text=None,from_obj=None,session=None,options=None,**kwargs):
+ """
+ Emits something to a session attached to the object.
+
+ Args:
+ text (str or tuple, optional): The message to send. This
+ is treated internally like any send-command, so its
+ value can be a tuple if sending multiple arguments to
+ the `text` oob command.
+ from_obj (obj or list, optional): object that is sending. If
+ given, at_msg_send will be called. This value will be
+ passed on to the protocol. If iterable, will execute hook
+ on all entities in it.
+ session (Session or list, optional): Session or list of
+ Sessions to relay data to, if any. If set, will force send
+ to these sessions. If unset, who receives the message
+ depends on the MULTISESSION_MODE.
+ options (dict, optional): Message-specific option-value
+ pairs. These will be applied at the protocol level.
+ Keyword Args:
+ any (string or tuples): All kwarg keys not listed above
+ will be treated as send-command names and their arguments
+ (which can be a string or a tuple).
+
+ Notes:
+ `at_msg_receive` will be called on this Object.
+ All extra kwargs will be passed on to the protocol.
+
+ """
+ # try send hooks
+ iffrom_obj:
+ forobjinmake_iter(from_obj):
+ try:
+ obj.at_msg_send(text=text,to_obj=self,**kwargs)
+ exceptException:
+ logger.log_trace()
+ kwargs["options"]=options
+ try:
+ ifnotself.at_msg_receive(text=text,from_obj=from_obj,**kwargs):
+ # if at_msg_receive returns false, we abort message to this object
+ return
+ exceptException:
+ logger.log_trace()
+
+ iftextisnotNone:
+ ifnot(isinstance(text,str)orisinstance(text,tuple)):
+ # sanitize text before sending across the wire
+ try:
+ text=to_str(text)
+ exceptException:
+ text=repr(text)
+ kwargs["text"]=text
+
+ # relay to session(s)
+ sessions=make_iter(session)ifsessionelseself.sessions.all()
+ forsessioninsessions:
+ session.data_out(**kwargs)
+
+
[docs]deffor_contents(self,func,exclude=None,**kwargs):
+ """
+ Runs a function on every object contained within this one.
+
+ Args:
+ func (callable): Function to call. This must have the
+ formal call sign func(obj, **kwargs), where obj is the
+ object currently being processed and `**kwargs` are
+ passed on from the call to `for_contents`.
+ exclude (list, optional): A list of object not to call the
+ function on.
+
+ Keyword Args:
+ Keyword arguments will be passed to the function for all objects.
+ """
+ contents=self.contents
+ ifexclude:
+ exclude=make_iter(exclude)
+ contents=[objforobjincontentsifobjnotinexclude]
+ forobjincontents:
+ func(obj,**kwargs)
+
+
[docs]defmsg_contents(self,text=None,exclude=None,from_obj=None,mapping=None,**kwargs):
+ """
+ Emits a message to all objects inside this object.
+
+ Args:
+ text (str or tuple): Message to send. If a tuple, this should be
+ on the valid OOB outmessage form `(message, {kwargs})`,
+ where kwargs are optional data passed to the `text`
+ outputfunc.
+ exclude (list, optional): A list of objects not to send to.
+ from_obj (Object, optional): An object designated as the
+ "sender" of the message. See `DefaultObject.msg()` for
+ more info.
+ mapping (dict, optional): A mapping of formatting keys
+ `{"key":<object>, "key2":<object2>,...}. The keys
+ must match `{key}` markers in the `text` if this is a string or
+ in the internal `message` if `text` is a tuple. These
+ formatting statements will be
+ replaced by the return of `<object>.get_display_name(looker)`
+ for every looker in contents that receives the
+ message. This allows for every object to potentially
+ get its own customized string.
+ Keyword Args:
+ Keyword arguments will be passed on to `obj.msg()` for all
+ messaged objects.
+
+ Notes:
+ The `mapping` argument is required if `message` contains
+ {}-style format syntax. The keys of `mapping` should match
+ named format tokens, and its values will have their
+ `get_display_name()` function called for each object in
+ the room before substitution. If an item in the mapping does
+ not have `get_display_name()`, its string value will be used.
+
+ Example:
+ Say Char is a Character object and Npc is an NPC object:
+
+ char.location.msg_contents(
+ "{attacker} kicks {defender}",
+ mapping=dict(attacker=char, defender=npc), exclude=(char, npc))
+
+ This will result in everyone in the room seeing 'Char kicks NPC'
+ where everyone may potentially see different results for Char and Npc
+ depending on the results of `char.get_display_name(looker)` and
+ `npc.get_display_name(looker)` for each particular onlooker
+
+ """
+ # we also accept an outcommand on the form (message, {kwargs})
+ is_outcmd=textandis_iter(text)
+ inmessage=text[0]ifis_outcmdelsetext
+ outkwargs=text[1]ifis_outcmdandlen(text)>1else{}
+
+ contents=self.contents
+ ifexclude:
+ exclude=make_iter(exclude)
+ contents=[objforobjincontentsifobjnotinexclude]
+ forobjincontents:
+ ifmapping:
+ substitutions={
+ t:sub.get_display_name(obj)ifhasattr(sub,"get_display_name")elsestr(sub)
+ fort,subinmapping.items()
+ }
+ outmessage=inmessage.format(**substitutions)
+ else:
+ outmessage=inmessage
+ obj.msg(text=(outmessage,outkwargs),from_obj=from_obj,**kwargs)
+
+
[docs]defmove_to(
+ self,
+ destination,
+ quiet=False,
+ emit_to_obj=None,
+ use_destination=True,
+ to_none=False,
+ move_hooks=True,
+ **kwargs,
+ ):
+ """
+ Moves this object to a new location.
+
+ Args:
+ destination (Object): Reference to the object to move to. This
+ can also be an exit object, in which case the
+ destination property is used as destination.
+ quiet (bool): If true, turn off the calling of the emit hooks
+ (announce_move_to/from etc)
+ emit_to_obj (Object): object to receive error messages
+ use_destination (bool): Default is for objects to use the "destination"
+ property of destinations as the target to move to. Turning off this
+ keyword allows objects to move "inside" exit objects.
+ to_none (bool): Allow destination to be None. Note that no hooks are run when
+ moving to a None location. If you want to run hooks, run them manually
+ (and make sure they can manage None locations).
+ move_hooks (bool): If False, turn off the calling of move-related hooks
+ (at_before/after_move etc) with quiet=True, this is as quiet a move
+ as can be done.
+
+ Keyword Args:
+ Passed on to announce_move_to and announce_move_from hooks.
+
+ Returns:
+ result (bool): True/False depending on if there were problems with the move.
+ This method may also return various error messages to the
+ `emit_to_obj`.
+
+ Notes:
+ No access checks are done in this method, these should be handled before
+ calling `move_to`.
+
+ The `DefaultObject` hooks called (if `move_hooks=True`) are, in order:
+
+ 1. `self.at_before_move(destination)` (if this returns False, move is aborted)
+ 2. `source_location.at_object_leave(self, destination)`
+ 3. `self.announce_move_from(destination)`
+ 4. (move happens here)
+ 5. `self.announce_move_to(source_location)`
+ 6. `destination.at_object_receive(self, source_location)`
+ 7. `self.at_after_move(source_location)`
+
+ """
+
+ deflogerr(string="",err=None):
+ """Simple log helper method"""
+ logger.log_trace()
+ self.msg("%s%s"%(string,""iferrisNoneelse" (%s)"%err))
+ return
+
+ errtxt=_("Couldn't perform move ('%s'). Contact an admin.")
+ ifnotemit_to_obj:
+ emit_to_obj=self
+
+ ifnotdestination:
+ ifto_none:
+ # immediately move to None. There can be no hooks called since
+ # there is no destination to call them with.
+ self.location=None
+ returnTrue
+ emit_to_obj.msg(_("The destination doesn't exist."))
+ returnFalse
+ ifdestination.destinationanduse_destination:
+ # traverse exits
+ destination=destination.destination
+
+ # Before the move, call eventual pre-commands.
+ ifmove_hooks:
+ try:
+ ifnotself.at_before_move(destination):
+ returnFalse
+ exceptExceptionaserr:
+ logerr(errtxt%"at_before_move()",err)
+ returnFalse
+
+ # Save the old location
+ source_location=self.location
+
+ # Call hook on source location
+ ifmove_hooksandsource_location:
+ try:
+ source_location.at_object_leave(self,destination)
+ exceptExceptionaserr:
+ logerr(errtxt%"at_object_leave()",err)
+ returnFalse
+
+ ifnotquiet:
+ # tell the old room we are leaving
+ try:
+ self.announce_move_from(destination,**kwargs)
+ exceptExceptionaserr:
+ logerr(errtxt%"at_announce_move()",err)
+ returnFalse
+
+ # Perform move
+ try:
+ self.location=destination
+ exceptExceptionaserr:
+ logerr(errtxt%"location change",err)
+ returnFalse
+
+ ifnotquiet:
+ # Tell the new room we are there.
+ try:
+ self.announce_move_to(source_location,**kwargs)
+ exceptExceptionaserr:
+ logerr(errtxt%"announce_move_to()",err)
+ returnFalse
+
+ ifmove_hooks:
+ # Perform eventual extra commands on the receiving location
+ # (the object has already arrived at this point)
+ try:
+ destination.at_object_receive(self,source_location)
+ exceptExceptionaserr:
+ logerr(errtxt%"at_object_receive()",err)
+ returnFalse
+
+ # Execute eventual extra commands on this object after moving it
+ # (usually calling 'look')
+ ifmove_hooks:
+ try:
+ self.at_after_move(source_location)
+ exceptExceptionaserr:
+ logerr(errtxt%"at_after_move",err)
+ returnFalse
+ returnTrue
+
+
[docs]defclear_exits(self):
+ """
+ Destroys all of the exits and any exits pointing to this
+ object as a destination.
+ """
+ forout_exitin[exiforexiinObjectDB.objects.get_contents(self)ifexi.db_destination]:
+ out_exit.delete()
+ forin_exitinObjectDB.objects.filter(db_destination=self):
+ in_exit.delete()
+
+
[docs]defclear_contents(self):
+ """
+ Moves all objects (accounts/things) to their home location or
+ to default home.
+ """
+ # Gather up everything that thinks this is its location.
+ default_home_id=int(settings.DEFAULT_HOME.lstrip("#"))
+ try:
+ default_home=ObjectDB.objects.get(id=default_home_id)
+ ifdefault_home.dbid==self.dbid:
+ # we are deleting default home!
+ default_home=None
+ exceptException:
+ string=_("Could not find default home '(#%d)'.")
+ logger.log_err(string%default_home_id)
+ default_home=None
+
+ forobjinself.contents:
+ home=obj.home
+ # Obviously, we can't send it back to here.
+ ifnothomeor(homeandhome.dbid==self.dbid):
+ obj.home=default_home
+ home=default_home
+
+ # If for some reason it's still None...
+ ifnothome:
+ string="Missing default home, '%s(#%d)' "
+ string+="now has a null location."
+ obj.location=None
+ obj.msg(_("Something went wrong! You are dumped into nowhere. Contact an admin."))
+ logger.log_err(string%(obj.name,obj.dbid))
+ return
+
+ ifobj.has_account:
+ ifhome:
+ string="Your current location has ceased to exist,"
+ string+=" moving you to %s(#%d)."
+ obj.msg(_(string)%(home.name,home.dbid))
+ else:
+ # Famous last words: The account should never see this.
+ string="This place should not exist ... contact an admin."
+ obj.msg(_(string))
+ obj.move_to(home)
+
+
[docs]@classmethod
+ defcreate(cls,key,account=None,**kwargs):
+ """
+ Creates a basic object with default parameters, unless otherwise
+ specified or extended.
+
+ Provides a friendlier interface to the utils.create_object() function.
+
+ Args:
+ key (str): Name of the new object.
+ account (Account): Account to attribute this object to.
+
+ Keyword Args:
+ description (str): Brief description for this object.
+ ip (str): IP address of creator (for object auditing).
+
+ Returns:
+ object (Object): A newly created object of the given typeclass.
+ errors (list): A list of errors in string form, if any.
+
+ """
+ errors=[]
+ obj=None
+
+ # Get IP address of creator, if available
+ ip=kwargs.pop("ip","")
+
+ # If no typeclass supplied, use this class
+ kwargs["typeclass"]=kwargs.pop("typeclass",cls)
+
+ # Set the supplied key as the name of the intended object
+ kwargs["key"]=key
+
+ # Get a supplied description, if any
+ description=kwargs.pop("description","")
+
+ # Create a sane lockstring if one wasn't supplied
+ lockstring=kwargs.get("locks")
+ ifaccountandnotlockstring:
+ lockstring=cls.lockstring.format(account_id=account.id)
+ kwargs["locks"]=lockstring
+
+ # Create object
+ try:
+ obj=create.create_object(**kwargs)
+
+ # Record creator id and creation IP
+ ifip:
+ obj.db.creator_ip=ip
+ ifaccount:
+ obj.db.creator_id=account.id
+
+ # Set description if there is none, or update it if provided
+ ifdescriptionornotobj.db.desc:
+ desc=descriptionifdescriptionelse"You see nothing special."
+ obj.db.desc=desc
+
+ exceptExceptionase:
+ errors.append("An error occurred while creating this '%s' object."%key)
+ logger.log_err(e)
+
+ returnobj,errors
+
+
[docs]defcopy(self,new_key=None,**kwargs):
+ """
+ Makes an identical copy of this object, identical except for a
+ new dbref in the database. If you want to customize the copy
+ by changing some settings, use ObjectDB.object.copy_object()
+ directly.
+
+ Args:
+ new_key (string): New key/name of copied object. If new_key is not
+ specified, the copy will be named <old_key>_copy by default.
+ Returns:
+ copy (Object): A copy of this object.
+
+ """
+
+ deffind_clone_key():
+ """
+ Append 01, 02 etc to obj.key. Checks next higher number in the
+ same location, then adds the next number available
+
+ returns the new clone name on the form keyXX
+ """
+ key=self.key
+ num=sum(
+ 1
+ forobjinself.location.contents
+ ifobj.key.startswith(key)andobj.key.lstrip(key).isdigit()
+ )
+ return"%s%03i"%(key,num)
+
+ new_key=new_keyorfind_clone_key()
+ new_obj=ObjectDB.objects.copy_object(self,new_key=new_key,**kwargs)
+ self.at_object_post_copy(new_obj,**kwargs)
+ returnnew_obj
+
+
[docs]defat_object_post_copy(self,new_obj,**kwargs):
+ """
+ Called by DefaultObject.copy(). Meant to be overloaded. In case there's extra data not covered by
+ .copy(), this can be used to deal with it.
+
+ Args:
+ new_obj (Object): The new Copy of this object.
+
+ Returns:
+ None
+ """
+ pass
+
+
[docs]defdelete(self):
+ """
+ Deletes this object. Before deletion, this method makes sure
+ to move all contained objects to their respective home
+ locations, as well as clean up all exits to/from the object.
+
+ Returns:
+ noerror (bool): Returns whether or not the delete completed
+ successfully or not.
+
+ """
+ global_ScriptDB
+ ifnot_ScriptDB:
+ fromevennia.scripts.modelsimportScriptDBas_ScriptDB
+
+ ifnotself.pkornotself.at_object_delete():
+ # This object has already been deleted,
+ # or the pre-delete check return False
+ returnFalse
+
+ # See if we need to kick the account off.
+
+ forsessioninself.sessions.all():
+ session.msg(_("Your character {key} has been destroyed.").format(key=self.key))
+ # no need to disconnect, Account just jumps to OOC mode.
+ # sever the connection (important!)
+ ifself.account:
+ # Remove the object from playable characters list
+ ifselfinself.account.db._playable_characters:
+ self.account.db._playable_characters=[
+ xforxinself.account.db._playable_charactersifx!=self
+ ]
+ forsessioninself.sessions.all():
+ self.account.unpuppet_object(session)
+
+ self.account=None
+
+ forscriptin_ScriptDB.objects.get_all_scripts_on_obj(self):
+ script.stop()
+
+ # Destroy any exits to and from this room, if any
+ self.clear_exits()
+ # Clear out any non-exit objects located within the object
+ self.clear_contents()
+ self.attributes.clear()
+ self.nicks.clear()
+ self.aliases.clear()
+ self.location=None# this updates contents_cache for our location
+
+ # Perform the deletion of the object
+ super().delete()
+ returnTrue
+
+
[docs]defaccess(
+ self,accessing_obj,access_type="read",default=False,no_superuser_bypass=False,**kwargs
+ ):
+ """
+ Determines if another object has permission to access this object
+ in whatever way.
+
+ Args:
+ accessing_obj (Object): Object trying to access this one.
+ access_type (str, optional): Type of access sought.
+ default (bool, optional): What to return if no lock of access_type was found.
+ no_superuser_bypass (bool, optional): If `True`, don't skip
+ lock check for superuser (be careful with this one).
+
+ Keyword Args:
+ Passed on to the at_access hook along with the result of the access check.
+
+ """
+ result=super().access(
+ accessing_obj,
+ access_type=access_type,
+ default=default,
+ no_superuser_bypass=no_superuser_bypass,
+ )
+ self.at_access(result,accessing_obj,access_type,**kwargs)
+ returnresult
+
+ #
+ # Hook methods
+ #
+
+
[docs]defat_first_save(self):
+ """
+ This is called by the typeclass system whenever an instance of
+ this class is saved for the first time. It is a generic hook
+ for calling the startup hooks for the various game entities.
+ When overloading you generally don't overload this but
+ overload the hooks called by this method.
+
+ """
+ self.basetype_setup()
+ self.at_object_creation()
+
+ ifhasattr(self,"_createdict"):
+ # this will only be set if the utils.create function
+ # was used to create the object. We want the create
+ # call's kwargs to override the values set by hooks.
+ cdict=self._createdict
+ updates=[]
+ ifnotcdict.get("key"):
+ ifnotself.db_key:
+ self.db_key="#%i"%self.dbid
+ updates.append("db_key")
+ elifself.key!=cdict.get("key"):
+ updates.append("db_key")
+ self.db_key=cdict["key"]
+ ifcdict.get("location")andself.location!=cdict["location"]:
+ self.db_location=cdict["location"]
+ updates.append("db_location")
+ ifcdict.get("home")andself.home!=cdict["home"]:
+ self.home=cdict["home"]
+ updates.append("db_home")
+ ifcdict.get("destination")andself.destination!=cdict["destination"]:
+ self.destination=cdict["destination"]
+ updates.append("db_destination")
+ ifupdates:
+ self.save(update_fields=updates)
+
+ ifcdict.get("permissions"):
+ self.permissions.batch_add(*cdict["permissions"])
+ ifcdict.get("locks"):
+ self.locks.add(cdict["locks"])
+ ifcdict.get("aliases"):
+ self.aliases.batch_add(*cdict["aliases"])
+ ifcdict.get("location"):
+ cdict["location"].at_object_receive(self,None)
+ self.at_after_move(None)
+ ifcdict.get("tags"):
+ # this should be a list of tags, tuples (key, category) or (key, category, data)
+ self.tags.batch_add(*cdict["tags"])
+ ifcdict.get("attributes"):
+ # this should be tuples (key, val, ...)
+ self.attributes.batch_add(*cdict["attributes"])
+ ifcdict.get("nattributes"):
+ # this should be a dict of nattrname:value
+ forkey,valueincdict["nattributes"]:
+ self.nattributes.add(key,value)
+
+ delself._createdict
+
+ self.basetype_posthook_setup()
+
+ # hooks called by the game engine #
+
+
[docs]defbasetype_setup(self):
+ """
+ This sets up the default properties of an Object, just before
+ the more general at_object_creation.
+
+ You normally don't need to change this unless you change some
+ fundamental things like names of permission groups.
+
+ """
+ # the default security setup fallback for a generic
+ # object. Overload in child for a custom setup. Also creation
+ # commands may set this (create an item and you should be its
+ # controller, for example)
+
+ self.locks.add(
+ ";".join(
+ [
+ "control:perm(Developer)",# edit locks/permissions, delete
+ "examine:perm(Builder)",# examine properties
+ "view:all()",# look at object (visibility)
+ "edit:perm(Admin)",# edit properties/attributes
+ "delete:perm(Admin)",# delete object
+ "get:all()",# pick up object
+ "drop:holds()",# drop only that which you hold
+ "call:true()",# allow to call commands on this object
+ "tell:perm(Admin)",# allow emits to this object
+ "puppet:pperm(Developer)",
+ ]
+ )
+ )# lock down puppeting only to staff by default
+
+
[docs]defbasetype_posthook_setup(self):
+ """
+ Called once, after basetype_setup and at_object_creation. This
+ should generally not be overloaded unless you are redefining
+ how a room/exit/object works. It allows for basetype-like
+ setup after the object is created. An example of this is
+ EXITs, who need to know keys, aliases, locks etc to set up
+ their exit-cmdsets.
+
+ """
+ pass
+
+
[docs]defat_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+
+ """
+ pass
+
+
[docs]defat_object_delete(self):
+ """
+ Called just before the database object is permanently
+ delete()d from the database. If this method returns False,
+ deletion is aborted.
+
+ """
+ returnTrue
+
+
[docs]defat_init(self):
+ """
+ This is always called whenever this object is initiated --
+ that is, whenever it its typeclass is cached from memory. This
+ happens on-demand first time the object is used or activated
+ in some way after being created but also after each server
+ restart or reload.
+
+ """
+ pass
+
+
[docs]defat_cmdset_get(self,**kwargs):
+ """
+ Called just before cmdsets on this object are requested by the
+ command handler. If changes need to be done on the fly to the
+ cmdset before passing them on to the cmdhandler, this is the
+ place to do it. This is called also if the object currently
+ have no cmdsets.
+
+ Keyword Args:
+ caller (Session, Object or Account): The caller requesting
+ this cmdset.
+
+ """
+ pass
+
+
[docs]defat_pre_puppet(self,account,session=None,**kwargs):
+ """
+ Called just before an Account connects to this object to puppet
+ it.
+
+ Args:
+ account (Account): This is the connecting account.
+ session (Session): Session controlling the connection.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_post_puppet(self,**kwargs):
+ """
+ Called just after puppeting has been completed and all
+ Account<->Object links have been established.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+ Note:
+ You can use `self.account` and `self.sessions.get()` to get
+ account and sessions at this point; the last entry in the
+ list from `self.sessions.get()` is the latest Session
+ puppeting this Object.
+
+ """
+ self.msg(f"You become |w{self.key}|n.")
+ self.account.db._last_puppet=self
+
+
[docs]defat_pre_unpuppet(self,**kwargs):
+ """
+ Called just before beginning to un-connect a puppeting from
+ this Account.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+ Note:
+ You can use `self.account` and `self.sessions.get()` to get
+ account and sessions at this point; the last entry in the
+ list from `self.sessions.get()` is the latest Session
+ puppeting this Object.
+
+ """
+ pass
+
+
[docs]defat_post_unpuppet(self,account,session=None,**kwargs):
+ """
+ Called just after the Account successfully disconnected from
+ this object, severing all connections.
+
+ Args:
+ account (Account): The account object that just disconnected
+ from this object.
+ session (Session): Session id controlling the connection that
+ just disconnected.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_server_reload(self):
+ """
+ This hook is called whenever the server is shutting down for
+ restart/reboot. If you want to, for example, save non-persistent
+ properties across a restart, this is the place to do it.
+
+ """
+ pass
+
+
[docs]defat_server_shutdown(self):
+ """
+ This hook is called whenever the server is shutting down fully
+ (i.e. not for a restart).
+
+ """
+ pass
+
+
[docs]defat_access(self,result,accessing_obj,access_type,**kwargs):
+ """
+ This is called with the result of an access call, along with
+ any kwargs used for that call. The return of this method does
+ not affect the result of the lock check. It can be used e.g. to
+ customize error messages in a central location or other effects
+ based on the access result.
+
+ Args:
+ result (bool): The outcome of the access call.
+ accessing_obj (Object or Account): The entity trying to gain access.
+ access_type (str): The type of access that was requested.
+
+ Keyword Args:
+ Not used by default, added for possible expandability in a
+ game.
+
+ """
+ pass
+
+ # hooks called when moving the object
+
+
[docs]defat_before_move(self,destination,**kwargs):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # return has_perm(self, destination, "can_move")
+ returnTrue
+
+
[docs]defannounce_move_from(self,destination,msg=None,mapping=None,**kwargs):
+ """
+ Called if the move is to be announced. This is
+ called while we are still standing in the old
+ location.
+
+ Args:
+ destination (Object): The place we are going to.
+ msg (str, optional): a replacement message.
+ mapping (dict, optional): additional mapping objects.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ You can override this method and call its parent with a
+ message to simply change the default message. In the string,
+ you can use the following as mappings (between braces):
+ object: the object which is moving.
+ exit: the exit from which the object is moving (if found).
+ origin: the location of the object before the move.
+ destination: the location of the object after moving.
+
+ """
+ ifnotself.location:
+ return
+ ifmsg:
+ string=msg
+ else:
+ string="{object} is leaving {origin}, heading for {destination}."
+
+ location=self.location
+ exits=[
+ oforoinlocation.contentsifo.locationislocationando.destinationisdestination
+ ]
+ ifnotmapping:
+ mapping={}
+
+ mapping.update(
+ {
+ "object":self,
+ "exit":exits[0]ifexitselse"somewhere",
+ "origin":locationor"nowhere",
+ "destination":destinationor"nowhere",
+ }
+ )
+
+ location.msg_contents(string,exclude=(self,),from_obj=self,mapping=mapping)
+
+
[docs]defannounce_move_to(self,source_location,msg=None,mapping=None,**kwargs):
+ """
+ Called after the move if the move was not quiet. At this point
+ we are standing in the new location.
+
+ Args:
+ source_location (Object): The place we came from
+ msg (str, optional): the replacement message if location.
+ mapping (dict, optional): additional mapping objects.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ You can override this method and call its parent with a
+ message to simply change the default message. In the string,
+ you can use the following as mappings (between braces):
+ object: the object which is moving.
+ exit: the exit from which the object is moving (if found).
+ origin: the location of the object before the move.
+ destination: the location of the object after moving.
+
+ """
+
+ ifnotsource_locationandself.location.has_account:
+ # This was created from nowhere and added to an account's
+ # inventory; it's probably the result of a create command.
+ string="You now have %s in your possession."%self.get_display_name(self.location)
+ self.location.msg(string)
+ return
+
+ ifsource_location:
+ ifmsg:
+ string=msg
+ else:
+ string="{object} arrives to {destination} from {origin}."
+ else:
+ string="{object} arrives to {destination}."
+
+ origin=source_location
+ destination=self.location
+ exits=[]
+ iforigin:
+ exits=[
+ o
+ foroindestination.contents
+ ifo.locationisdestinationando.destinationisorigin
+ ]
+
+ ifnotmapping:
+ mapping={}
+
+ mapping.update(
+ {
+ "object":self,
+ "exit":exits[0]ifexitselse"somewhere",
+ "origin":originor"nowhere",
+ "destination":destinationor"nowhere",
+ }
+ )
+
+ destination.msg_contents(string,exclude=(self,),from_obj=self,mapping=mapping)
+
+
[docs]defat_after_move(self,source_location,**kwargs):
+ """
+ Called after move has completed, regardless of quiet mode or
+ not. Allows changes to the object due to the location it is
+ now in.
+
+ Args:
+ source_location (Object): Wwhere we came from. This may be `None`.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_object_leave(self,moved_obj,target_location,**kwargs):
+ """
+ Called just before an object leaves from inside this object
+
+ Args:
+ moved_obj (Object): The object leaving
+ target_location (Object): Where `moved_obj` is going.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_object_receive(self,moved_obj,source_location,**kwargs):
+ """
+ Called after an object has been moved into this object.
+
+ Args:
+ moved_obj (Object): The object moved into this one
+ source_location (Object): Where `moved_object` came from.
+ Note that this could be `None`.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_traverse(self,traversing_object,target_location,**kwargs):
+ """
+ This hook is responsible for handling the actual traversal,
+ normally by calling
+ `traversing_object.move_to(target_location)`. It is normally
+ only implemented by Exit objects. If it returns False (usually
+ because `move_to` returned False), `at_after_traverse` below
+ should not be called and instead `at_failed_traverse` should be
+ called.
+
+ Args:
+ traversing_object (Object): Object traversing us.
+ target_location (Object): Where target is going.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_after_traverse(self,traversing_object,source_location,**kwargs):
+ """
+ Called just after an object successfully used this object to
+ traverse to another object (i.e. this object is a type of
+ Exit)
+
+ Args:
+ traversing_object (Object): The object traversing us.
+ source_location (Object): Where `traversing_object` came from.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ The target location should normally be available as `self.destination`.
+ """
+ pass
+
+
[docs]defat_failed_traverse(self,traversing_object,**kwargs):
+ """
+ This is called if an object fails to traverse this object for
+ some reason.
+
+ Args:
+ traversing_object (Object): The object that failed traversing us.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ Using the default exits, this hook will not be called if an
+ Attribute `err_traverse` is defined - this will in that case be
+ read for an error string instead.
+
+ """
+ pass
+
+
[docs]defat_msg_receive(self,text=None,from_obj=None,**kwargs):
+ """
+ This hook is called whenever someone sends a message to this
+ object using the `msg` method.
+
+ Note that from_obj may be None if the sender did not include
+ itself as an argument to the obj.msg() call - so you have to
+ check for this. .
+
+ Consider this a pre-processing method before msg is passed on
+ to the user session. If this method returns False, the msg
+ will not be passed on.
+
+ Args:
+ text (str, optional): The message received.
+ from_obj (any, optional): The object sending the message.
+
+ Keyword Args:
+ This includes any keywords sent to the `msg` method.
+
+ Returns:
+ receive (bool): If this message should be received.
+
+ Notes:
+ If this method returns False, the `msg` operation
+ will abort without sending the message.
+
+ """
+ returnTrue
+
+
[docs]defat_msg_send(self,text=None,to_obj=None,**kwargs):
+ """
+ This is a hook that is called when *this* object sends a
+ message to another object with `obj.msg(text, to_obj=obj)`.
+
+ Args:
+ text (str, optional): Text to send.
+ to_obj (any, optional): The object to send to.
+
+ Keyword Args:
+ Keywords passed from msg()
+
+ Notes:
+ Since this method is executed by `from_obj`, if no `from_obj`
+ was passed to `DefaultCharacter.msg` this hook will never
+ get called.
+
+ """
+ pass
+
+ # hooks called by the default cmdset.
+
+
[docs]defreturn_appearance(self,looker,**kwargs):
+ """
+ This formats a description. It is the hook a 'look' command
+ should call.
+
+ Args:
+ looker (Object): Object doing the looking.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+ """
+ ifnotlooker:
+ return""
+ # get and identify all objects
+ visible=(conforconinself.contentsifcon!=lookerandcon.access(looker,"view"))
+ exits,users,things=[],[],defaultdict(list)
+ forconinvisible:
+ key=con.get_display_name(looker)
+ ifcon.destination:
+ exits.append(key)
+ elifcon.has_account:
+ users.append("|c%s|n"%key)
+ else:
+ # things can be pluralized
+ things[key].append(con)
+ # get description, build string
+ string="|c%s|n\n"%self.get_display_name(looker)
+ desc=self.db.desc
+ ifdesc:
+ string+="%s"%desc
+ ifexits:
+ string+="\n|wExits:|n "+list_to_string(exits)
+ ifusersorthings:
+ # handle pluralization of things (never pluralize users)
+ thing_strings=[]
+ forkey,itemlistinsorted(things.items()):
+ nitem=len(itemlist)
+ ifnitem==1:
+ key,_=itemlist[0].get_numbered_name(nitem,looker,key=key)
+ else:
+ key=[item.get_numbered_name(nitem,looker,key=key)[1]foriteminitemlist][
+ 0
+ ]
+ thing_strings.append(key)
+
+ string+="\n|wYou see:|n "+list_to_string(users+thing_strings)
+
+ returnstring
+
+
[docs]defat_look(self,target,**kwargs):
+ """
+ Called when this object performs a look. It allows to
+ customize just what this means. It will not itself
+ send any data.
+
+ Args:
+ target (Object): The target being looked at. This is
+ commonly an object or the current location. It will
+ be checked for the "view" type access.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call. This will be passed into
+ return_appearance, get_display_name and at_desc but is not used
+ by default.
+
+ Returns:
+ lookstring (str): A ready-processed look string
+ potentially ready to return to the looker.
+
+ """
+ ifnottarget.access(self,"view"):
+ try:
+ return"Could not view '%s'."%target.get_display_name(self,**kwargs)
+ exceptAttributeError:
+ return"Could not view '%s'."%target.key
+
+ description=target.return_appearance(self,**kwargs)
+
+ # the target's at_desc() method.
+ # this must be the last reference to target so it may delete itself when acted on.
+ target.at_desc(looker=self,**kwargs)
+
+ returndescription
+
+
[docs]defat_desc(self,looker=None,**kwargs):
+ """
+ This is called whenever someone looks at this object.
+
+ Args:
+ looker (Object, optional): The object requesting the description.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_before_get(self,getter,**kwargs):
+ """
+ Called by the default `get` command before this object has been
+ picked up.
+
+ Args:
+ getter (Object): The object about to get this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shouldget (bool): If the object should be gotten or not.
+
+ Notes:
+ If this method returns False/None, the getting is cancelled
+ before it is even started.
+ """
+ returnTrue
+
+
[docs]defat_get(self,getter,**kwargs):
+ """
+ Called by the default `get` command when this object has been
+ picked up.
+
+ Args:
+ getter (Object): The object getting this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This hook cannot stop the pickup from happening. Use
+ permissions or the at_before_get() hook for that.
+
+ """
+ pass
+
+
[docs]defat_before_give(self,giver,getter,**kwargs):
+ """
+ Called by the default `give` command before this object has been
+ given.
+
+ Args:
+ giver (Object): The object about to give this object.
+ getter (Object): The object about to get this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shouldgive (bool): If the object should be given or not.
+
+ Notes:
+ If this method returns False/None, the giving is cancelled
+ before it is even started.
+
+ """
+ returnTrue
+
+
[docs]defat_give(self,giver,getter,**kwargs):
+ """
+ Called by the default `give` command when this object has been
+ given.
+
+ Args:
+ giver (Object): The object giving this object.
+ getter (Object): The object getting this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This hook cannot stop the give from happening. Use
+ permissions or the at_before_give() hook for that.
+
+ """
+ pass
+
+
[docs]defat_before_drop(self,dropper,**kwargs):
+ """
+ Called by the default `drop` command before this object has been
+ dropped.
+
+ Args:
+ dropper (Object): The object which will drop this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shoulddrop (bool): If the object should be dropped or not.
+
+ Notes:
+ If this method returns False/None, the dropping is cancelled
+ before it is even started.
+
+ """
+ ifnotself.locks.get("drop"):
+ # TODO: This if-statment will be removed in Evennia 1.0
+ returnTrue
+ ifnotself.access(dropper,"drop",default=False):
+ dropper.msg(f"You cannot drop {self.get_display_name(dropper)}")
+ returnFalse
+ returnTrue
+
+
[docs]defat_drop(self,dropper,**kwargs):
+ """
+ Called by the default `drop` command when this object has been
+ dropped.
+
+ Args:
+ dropper (Object): The object which just dropped this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This hook cannot stop the drop from happening. Use
+ permissions or the at_before_drop() hook for that.
+
+ """
+ pass
+
+
[docs]defat_before_say(self,message,**kwargs):
+ """
+ Before the object says something.
+
+ This hook is by default used by the 'say' and 'whisper'
+ commands as used by this command it is called before the text
+ is said/whispered and can be used to customize the outgoing
+ text from the object. Returning `None` aborts the command.
+
+ Args:
+ message (str): The suggested say/whisper text spoken by self.
+ Keyword Args:
+ whisper (bool): If True, this is a whisper rather than
+ a say. This is sent by the whisper command by default.
+ Other verbal commands could use this hook in similar
+ ways.
+ receivers (Object or iterable): If set, this is the target or targets for the say/whisper.
+
+ Returns:
+ message (str): The (possibly modified) text to be spoken.
+
+ """
+ returnmessage
+
+
[docs]defat_say(
+ self,
+ message,
+ msg_self=None,
+ msg_location=None,
+ receivers=None,
+ msg_receivers=None,
+ **kwargs,
+ ):
+ """
+ Display the actual say (or whisper) of self.
+
+ This hook should display the actual say/whisper of the object in its
+ location. It should both alert the object (self) and its
+ location that some text is spoken. The overriding of messages or
+ `mapping` allows for simple customization of the hook without
+ re-writing it completely.
+
+ Args:
+ message (str): The message to convey.
+ msg_self (bool or str, optional): If boolean True, echo `message` to self. If a string,
+ return that message. If False or unset, don't echo to self.
+ msg_location (str, optional): The message to echo to self's location.
+ receivers (Object or iterable, optional): An eventual receiver or receivers of the message
+ (by default only used by whispers).
+ msg_receivers(str): Specific message to pass to the receiver(s). This will parsed
+ with the {receiver} placeholder replaced with the given receiver.
+ Keyword Args:
+ whisper (bool): If this is a whisper rather than a say. Kwargs
+ can be used by other verbal commands in a similar way.
+ mapping (dict): Pass an additional mapping to the message.
+
+ Notes:
+
+
+ Messages can contain {} markers. These are substituted against the values
+ passed in the `mapping` argument.
+
+ msg_self = 'You say: "{speech}"'
+ msg_location = '{object} says: "{speech}"'
+ msg_receivers = '{object} whispers: "{speech}"'
+
+ Supported markers by default:
+ {self}: text to self-reference with (default 'You')
+ {speech}: the text spoken/whispered by self.
+ {object}: the object speaking.
+ {receiver}: replaced with a single receiver only for strings meant for a specific
+ receiver (otherwise 'None').
+ {all_receivers}: comma-separated list of all receivers,
+ if more than one, otherwise same as receiver
+ {location}: the location where object is.
+
+ """
+ msg_type="say"
+ ifkwargs.get("whisper",False):
+ # whisper mode
+ msg_type="whisper"
+ msg_self=(
+ '{self} whisper to {all_receivers}, "|n{speech}|n"'
+ ifmsg_selfisTrue
+ elsemsg_self
+ )
+ msg_receivers=msg_receiversor'{object} whispers: "|n{speech}|n"'
+ msg_location=None
+ else:
+ msg_self='{self} say, "|n{speech}|n"'ifmsg_selfisTrueelsemsg_self
+ msg_location=msg_locationor'{object} says, "{speech}"'
+ msg_receivers=msg_receiversormessage
+
+ custom_mapping=kwargs.get("mapping",{})
+ receivers=make_iter(receivers)ifreceiverselseNone
+ location=self.location
+
+ ifmsg_self:
+ self_mapping={
+ "self":"You",
+ "object":self.get_display_name(self),
+ "location":location.get_display_name(self)iflocationelseNone,
+ "receiver":None,
+ "all_receivers":", ".join(recv.get_display_name(self)forrecvinreceivers)
+ ifreceivers
+ elseNone,
+ "speech":message,
+ }
+ self_mapping.update(custom_mapping)
+ self.msg(text=(msg_self.format(**self_mapping),{"type":msg_type}),from_obj=self)
+
+ ifreceiversandmsg_receivers:
+ receiver_mapping={
+ "self":"You",
+ "object":None,
+ "location":None,
+ "receiver":None,
+ "all_receivers":None,
+ "speech":message,
+ }
+ forreceiverinmake_iter(receivers):
+ individual_mapping={
+ "object":self.get_display_name(receiver),
+ "location":location.get_display_name(receiver),
+ "receiver":receiver.get_display_name(receiver),
+ "all_receivers":", ".join(recv.get_display_name(recv)forrecvinreceivers)
+ ifreceivers
+ elseNone,
+ }
+ receiver_mapping.update(individual_mapping)
+ receiver_mapping.update(custom_mapping)
+ receiver.msg(
+ text=(msg_receivers.format(**receiver_mapping),{"type":msg_type}),
+ from_obj=self,
+ )
+
+ ifself.locationandmsg_location:
+ location_mapping={
+ "self":"You",
+ "object":self,
+ "location":location,
+ "all_receivers":", ".join(str(recv)forrecvinreceivers)ifreceiverselseNone,
+ "receiver":None,
+ "speech":message,
+ }
+ location_mapping.update(custom_mapping)
+ exclude=[]
+ ifmsg_self:
+ exclude.append(self)
+ ifreceivers:
+ exclude.extend(receivers)
+ self.location.msg_contents(
+ text=(msg_location,{"type":msg_type}),
+ from_obj=self,
+ exclude=exclude,
+ mapping=location_mapping,
+ )
+
+
+#
+# Base Character object
+#
+
+
+
[docs]classDefaultCharacter(DefaultObject):
+ """
+ This implements an Object puppeted by a Session - that is,
+ a character avatar controlled by an account.
+
+ """
+
+ # lockstring of newly created rooms, for easy overloading.
+ # Will be formatted with the appropriate attributes.
+ lockstring=(
+ "puppet:id({character_id}) or pid({account_id}) or perm(Developer) or pperm(Developer);"
+ "delete:id({account_id}) or perm(Admin)"
+ )
+
+
[docs]@classmethod
+ defcreate(cls,key,account=None,**kwargs):
+ """
+ Creates a basic Character with default parameters, unless otherwise
+ specified or extended.
+
+ Provides a friendlier interface to the utils.create_character() function.
+
+ Args:
+ key (str): Name of the new Character.
+ account (obj, optional): Account to associate this Character with.
+ If unset supplying None-- it will
+ change the default lockset and skip creator attribution.
+
+ Keyword Args:
+ description (str): Brief description for this object.
+ ip (str): IP address of creator (for object auditing).
+ All other kwargs will be passed into the create_object call.
+
+ Returns:
+ character (Object): A newly created Character of the given typeclass.
+ errors (list): A list of errors in string form, if any.
+
+ """
+ errors=[]
+ obj=None
+ # Get IP address of creator, if available
+ ip=kwargs.pop("ip","")
+
+ # If no typeclass supplied, use this class
+ kwargs["typeclass"]=kwargs.pop("typeclass",cls)
+
+ # Set the supplied key as the name of the intended object
+ kwargs["key"]=key
+
+ # Get permissions
+ kwargs["permissions"]=kwargs.get("permissions",settings.PERMISSION_ACCOUNT_DEFAULT)
+
+ # Get description if provided
+ description=kwargs.pop("description","")
+
+ # Get locks if provided
+ locks=kwargs.pop("locks","")
+
+ try:
+ # Check to make sure account does not have too many chars
+ ifaccount:
+ iflen(account.characters)>=settings.MAX_NR_CHARACTERS:
+ errors.append("There are too many characters associated with this account.")
+ returnobj,errors
+
+ # Create the Character
+ obj=create.create_object(**kwargs)
+
+ # Record creator id and creation IP
+ ifip:
+ obj.db.creator_ip=ip
+ ifaccount:
+ obj.db.creator_id=account.id
+ ifobjnotinaccount.characters:
+ account.db._playable_characters.append(obj)
+
+ # Add locks
+ ifnotlocksandaccount:
+ # Allow only the character itself and the creator account to puppet this character (and Developers).
+ locks=cls.lockstring.format(**{"character_id":obj.id,"account_id":account.id})
+ elifnotlocksandnotaccount:
+ locks=cls.lockstring.format(**{"character_id":obj.id,"account_id":-1})
+
+ obj.locks.add(locks)
+
+ # If no description is set, set a default description
+ ifdescriptionornotobj.db.desc:
+ obj.db.desc=descriptionifdescriptionelse"This is a character."
+
+ exceptExceptionase:
+ errors.append("An error occurred while creating this '%s' object."%key)
+ logger.log_err(e)
+
+ returnobj,errors
+
+
[docs]defbasetype_setup(self):
+ """
+ Setup character-specific security.
+
+ You should normally not need to overload this, but if you do,
+ make sure to reproduce at least the two last commands in this
+ method (unless you want to fundamentally change how a
+ Character object works).
+
+ """
+ super().basetype_setup()
+ self.locks.add(
+ ";".join(["get:false()","call:false()"])# noone can pick up the character
+ )# no commands can be called on character from outside
+ # add the default cmdset
+ self.cmdset.add_default(settings.CMDSET_CHARACTER,permanent=True)
+
+
[docs]defat_after_move(self,source_location,**kwargs):
+ """
+ We make sure to look around after a move.
+
+ """
+ ifself.location.access(self,"view"):
+ self.msg(self.at_look(self.location))
+
+
[docs]defat_pre_puppet(self,account,session=None,**kwargs):
+ """
+ Return the character from storage in None location in `at_post_unpuppet`.
+ Args:
+ account (Account): This is the connecting account.
+ session (Session): Session controlling the connection.
+ """
+ if(
+ self.locationisNone
+ ):# Make sure character's location is never None before being puppeted.
+ # Return to last location (or home, which should always exist),
+ self.location=self.db.prelogout_locationifself.db.prelogout_locationelseself.home
+ self.location.at_object_receive(
+ self,None
+ )# and trigger the location's reception hook.
+ ifself.location:# If the character is verified to be somewhere,
+ self.db.prelogout_location=self.location# save location again to be sure.
+ else:
+ account.msg(
+ "|r%s has no location and no home is set.|n"%self,session=session
+ )# Note to set home.
+
+
[docs]defat_post_puppet(self,**kwargs):
+ """
+ Called just after puppeting has been completed and all
+ Account<->Object links have been established.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+ Note:
+ You can use `self.account` and `self.sessions.get()` to get
+ account and sessions at this point; the last entry in the
+ list from `self.sessions.get()` is the latest Session
+ puppeting this Object.
+
+ """
+ self.msg("\nYou become |c%s|n.\n"%self.name)
+ self.msg((self.at_look(self.location),{"type":"look"}),options=None)
+
+ defmessage(obj,from_obj):
+ obj.msg("%s has entered the game."%self.get_display_name(obj),from_obj=from_obj)
+
+ self.location.for_contents(message,exclude=[self],from_obj=self)
+
+
[docs]defat_post_unpuppet(self,account,session=None,**kwargs):
+ """
+ We stove away the character when the account goes ooc/logs off,
+ otherwise the character object will remain in the room also
+ after the account logged off ("headless", so to say).
+
+ Args:
+ account (Account): The account object that just disconnected
+ from this object.
+ session (Session): Session controlling the connection that
+ just disconnected.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+ """
+ ifnotself.sessions.count():
+ # only remove this char from grid if no sessions control it anymore.
+ ifself.location:
+
+ defmessage(obj,from_obj):
+ obj.msg("%s has left the game."%self.get_display_name(obj),from_obj=from_obj)
+
+ self.location.for_contents(message,exclude=[self],from_obj=self)
+ self.db.prelogout_location=self.location
+ self.location=None
+
+ @property
+ defidle_time(self):
+ """
+ Returns the idle time of the least idle session in seconds. If
+ no sessions are connected it returns nothing.
+ """
+ idle=[session.cmd_last_visibleforsessioninself.sessions.all()]
+ ifidle:
+ returntime.time()-float(max(idle))
+ returnNone
+
+ @property
+ defconnection_time(self):
+ """
+ Returns the maximum connection time of all connected sessions
+ in seconds. Returns nothing if there are no sessions.
+ """
+ conn=[session.conn_timeforsessioninself.sessions.all()]
+ ifconn:
+ returntime.time()-float(min(conn))
+ returnNone
+
+
+#
+# Base Room object
+
+
+
[docs]classDefaultRoom(DefaultObject):
+ """
+ This is the base room object. It's just like any Object except its
+ location is always `None`.
+ """
+
+ # lockstring of newly created rooms, for easy overloading.
+ # Will be formatted with the {id} of the creating object.
+ lockstring=(
+ "control:id({id}) or perm(Admin); "
+ "delete:id({id}) or perm(Admin); "
+ "edit:id({id}) or perm(Admin)"
+ )
+
+
[docs]@classmethod
+ defcreate(cls,key,account=None,**kwargs):
+ """
+ Creates a basic Room with default parameters, unless otherwise
+ specified or extended.
+
+ Provides a friendlier interface to the utils.create_object() function.
+
+ Args:
+ key (str): Name of the new Room.
+ account (obj, optional): Account to associate this Room with. If
+ given, it will be given specific control/edit permissions to this
+ object (along with normal Admin perms). If not given, default
+
+ Keyword Args:
+ description (str): Brief description for this object.
+ ip (str): IP address of creator (for object auditing).
+
+ Returns:
+ room (Object): A newly created Room of the given typeclass.
+ errors (list): A list of errors in string form, if any.
+
+ """
+ errors=[]
+ obj=None
+
+ # Get IP address of creator, if available
+ ip=kwargs.pop("ip","")
+
+ # If no typeclass supplied, use this class
+ kwargs["typeclass"]=kwargs.pop("typeclass",cls)
+
+ # Set the supplied key as the name of the intended object
+ kwargs["key"]=key
+
+ # Get who to send errors to
+ kwargs["report_to"]=kwargs.pop("report_to",account)
+
+ # Get description, if provided
+ description=kwargs.pop("description","")
+
+ # get locks if provided
+ locks=kwargs.pop("locks","")
+
+ try:
+ # Create the Room
+ obj=create.create_object(**kwargs)
+
+ # Add locks
+ ifnotlocksandaccount:
+ locks=cls.lockstring.format(**{"id":account.id})
+ elifnotlocksandnotaccount:
+ locks=cls.lockstring(**{"id":obj.id})
+
+ obj.locks.add(locks)
+
+ # Record creator id and creation IP
+ ifip:
+ obj.db.creator_ip=ip
+ ifaccount:
+ obj.db.creator_id=account.id
+
+ # If no description is set, set a default description
+ ifdescriptionornotobj.db.desc:
+ obj.db.desc=descriptionifdescriptionelse"This is a room."
+
+ exceptExceptionase:
+ errors.append("An error occurred while creating this '%s' object."%key)
+ logger.log_err(e)
+
+ returnobj,errors
+
+
[docs]defbasetype_setup(self):
+ """
+ Simple room setup setting locks to make sure the room
+ cannot be picked up.
+
+ """
+
+ super().basetype_setup()
+ self.locks.add(
+ ";".join(["get:false()","puppet:false()"])
+ )# would be weird to puppet a room ...
+ self.location=None
+
+
+#
+# Default Exit command, used by the base exit object
+#
+
+
[docs]classExitCommand(_COMMAND_DEFAULT_CLASS):
+ """
+ This is a command that simply cause the caller to traverse
+ the object it is attached to.
+
+ """
+
+ obj=None
+
+
[docs]deffunc(self):
+ """
+ Default exit traverse if no syscommand is defined.
+ """
+
+ ifself.obj.access(self.caller,"traverse"):
+ # we may traverse the exit.
+ self.obj.at_traverse(self.caller,self.obj.destination)
+ else:
+ # exit is locked
+ ifself.obj.db.err_traverse:
+ # if exit has a better error message, let's use it.
+ self.caller.msg(self.obj.db.err_traverse)
+ else:
+ # No shorthand error message. Call hook.
+ self.obj.at_failed_traverse(self.caller)
+
+
[docs]defget_extra_info(self,caller,**kwargs):
+ """
+ Shows a bit of information on where the exit leads.
+
+ Args:
+ caller (Object): The object (usually a character) that entered an ambiguous command.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ A string with identifying information to disambiguate the command, conventionally with a preceding space.
+ """
+ ifself.obj.destination:
+ return" (exit to %s)"%self.obj.destination.get_display_name(caller)
+ else:
+ return" (%s)"%self.obj.get_display_name(caller)
+
+
+#
+# Base Exit object
+
+
+
[docs]classDefaultExit(DefaultObject):
+ """
+ This is the base exit object - it connects a location to another.
+ This is done by the exit assigning a "command" on itself with the
+ same name as the exit object (to do this we need to remember to
+ re-create the command when the object is cached since it must be
+ created dynamically depending on what the exit is called). This
+ command (which has a high priority) will thus allow us to traverse
+ exits simply by giving the exit-object's name on its own.
+
+ """
+
+ exit_command=ExitCommand
+ priority=101
+
+ # lockstring of newly created exits, for easy overloading.
+ # Will be formatted with the {id} of the creating object.
+ lockstring=(
+ "control:id({id}) or perm(Admin); "
+ "delete:id({id}) or perm(Admin); "
+ "edit:id({id}) or perm(Admin)"
+ )
+
+ # Helper classes and methods to implement the Exit. These need not
+ # be overloaded unless one want to change the foundation for how
+ # Exits work. See the end of the class for hook methods to overload.
+
+
[docs]defcreate_exit_cmdset(self,exidbobj):
+ """
+ Helper function for creating an exit command set + command.
+
+ The command of this cmdset has the same name as the Exit
+ object and allows the exit to react when the account enter the
+ exit's name, triggering the movement between rooms.
+
+ Args:
+ exidbobj (Object): The DefaultExit object to base the command on.
+
+ """
+
+ # create an exit command. We give the properties here,
+ # to always trigger metaclass preparations
+ cmd=self.exit_command(
+ key=exidbobj.db_key.strip().lower(),
+ aliases=exidbobj.aliases.all(),
+ locks=str(exidbobj.locks),
+ auto_help=False,
+ destination=exidbobj.db_destination,
+ arg_regex=r"^$",
+ is_exit=True,
+ obj=exidbobj,
+ )
+ # create a cmdset
+ exit_cmdset=cmdset.CmdSet(None)
+ exit_cmdset.key="ExitCmdSet"
+ exit_cmdset.priority=self.priority
+ exit_cmdset.duplicates=True
+ # add command to cmdset
+ exit_cmdset.add(cmd)
+ returnexit_cmdset
+
+ # Command hooks
+
+
[docs]@classmethod
+ defcreate(cls,key,source,dest,account=None,**kwargs):
+ """
+ Creates a basic Exit with default parameters, unless otherwise
+ specified or extended.
+
+ Provides a friendlier interface to the utils.create_object() function.
+
+ Args:
+ key (str): Name of the new Exit, as it should appear from the
+ source room.
+ account (obj): Account to associate this Exit with.
+ source (Room): The room to create this exit in.
+ dest (Room): The room to which this exit should go.
+
+ Keyword Args:
+ description (str): Brief description for this object.
+ ip (str): IP address of creator (for object auditing).
+
+ Returns:
+ exit (Object): A newly created Room of the given typeclass.
+ errors (list): A list of errors in string form, if any.
+
+ """
+ errors=[]
+ obj=None
+
+ # Get IP address of creator, if available
+ ip=kwargs.pop("ip","")
+
+ # If no typeclass supplied, use this class
+ kwargs["typeclass"]=kwargs.pop("typeclass",cls)
+
+ # Set the supplied key as the name of the intended object
+ kwargs["key"]=key
+
+ # Get who to send errors to
+ kwargs["report_to"]=kwargs.pop("report_to",account)
+
+ # Set to/from rooms
+ kwargs["location"]=source
+ kwargs["destination"]=dest
+
+ description=kwargs.pop("description","")
+
+ locks=kwargs.get("locks","")
+
+ try:
+ # Create the Exit
+ obj=create.create_object(**kwargs)
+
+ # Set appropriate locks
+ ifnotlocksandaccount:
+ locks=cls.lockstring.format(**{"id":account.id})
+ elifnotlocksandnotaccount:
+ locks=cls.lockstring.format(**{"id":obj.id})
+ obj.locks.add(locks)
+
+ # Record creator id and creation IP
+ ifip:
+ obj.db.creator_ip=ip
+ ifaccount:
+ obj.db.creator_id=account.id
+
+ # If no description is set, set a default description
+ ifdescriptionornotobj.db.desc:
+ obj.db.desc=descriptionifdescriptionelse"This is an exit."
+
+ exceptExceptionase:
+ errors.append("An error occurred while creating this '%s' object."%key)
+ logger.log_err(e)
+
+ returnobj,errors
+
+
[docs]defbasetype_setup(self):
+ """
+ Setup exit-security
+
+ You should normally not need to overload this - if you do make
+ sure you include all the functionality in this method.
+
+ """
+ super().basetype_setup()
+
+ # setting default locks (overload these in at_object_creation()
+ self.locks.add(
+ ";".join(
+ [
+ "puppet:false()",# would be weird to puppet an exit ...
+ "traverse:all()",# who can pass through exit by default
+ "get:false()",# noone can pick up the exit
+ ]
+ )
+ )
+
+ # an exit should have a destination (this is replaced at creation time)
+ ifself.location:
+ self.destination=self.location
+
+
[docs]defat_cmdset_get(self,**kwargs):
+ """
+ Called just before cmdsets on this object are requested by the
+ command handler. If changes need to be done on the fly to the
+ cmdset before passing them on to the cmdhandler, this is the
+ place to do it. This is called also if the object currently
+ has no cmdsets.
+
+ Keyword Args:
+ force_init (bool): If `True`, force a re-build of the cmdset
+ (for example to update aliases).
+
+ """
+
+ if"force_init"inkwargsornotself.cmdset.has_cmdset("ExitCmdSet",must_be_default=True):
+ # we are resetting, or no exit-cmdset was set. Create one dynamically.
+ self.cmdset.add_default(self.create_exit_cmdset(self),permanent=False)
+
+
[docs]defat_init(self):
+ """
+ This is called when this objects is re-loaded from cache. When
+ that happens, we make sure to remove any old ExitCmdSet cmdset
+ (this most commonly occurs when renaming an existing exit)
+ """
+ self.cmdset.remove_default()
+
+
[docs]defat_traverse(self,traversing_object,target_location,**kwargs):
+ """
+ This implements the actual traversal. The traverse lock has
+ already been checked (in the Exit command) at this point.
+
+ Args:
+ traversing_object (Object): Object traversing us.
+ target_location (Object): Where target is going.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ source_location=traversing_object.location
+ iftraversing_object.move_to(target_location):
+ self.at_after_traverse(traversing_object,source_location)
+ else:
+ ifself.db.err_traverse:
+ # if exit has a better error message, let's use it.
+ traversing_object.msg(self.db.err_traverse)
+ else:
+ # No shorthand error message. Call hook.
+ self.at_failed_traverse(traversing_object)
+
+
[docs]defat_failed_traverse(self,traversing_object,**kwargs):
+ """
+ Overloads the default hook to implement a simple default error message.
+
+ Args:
+ traversing_object (Object): The object that failed traversing us.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ Using the default exits, this hook will not be called if an
+ Attribute `err_traverse` is defined - this will in that case be
+ read for an error string instead.
+
+ """
+ traversing_object.msg("You cannot go there.")
+"""
+
+OLC Prototype menu nodes
+
+"""
+
+importjson
+importre
+fromrandomimportchoice
+fromdjango.db.modelsimportQ
+fromdjango.confimportsettings
+fromevennia.objects.modelsimportObjectDB
+fromevennia.utils.evmenuimportEvMenu,list_node
+fromevennia.utilsimportevmore
+fromevennia.utils.ansiimportstrip_ansi
+fromevennia.utilsimportutils
+fromevennia.locks.lockhandlerimportget_all_lockfuncs
+fromevennia.prototypesimportprototypesasprotlib
+fromevennia.prototypesimportspawner
+
+# ------------------------------------------------------------
+#
+# OLC Prototype design menu
+#
+# ------------------------------------------------------------
+
+_MENU_CROP_WIDTH=15
+_MENU_ATTR_LITERAL_EVAL_ERROR=(
+ "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n"
+ "You also need to use correct Python syntax. Remember especially to put quotes around all "
+ "strings inside lists and dicts.|n"
+)
+
+
+# Helper functions
+
+
+def_get_menu_prototype(caller):
+ """Return currently active menu prototype."""
+ prototype=None
+ ifhasattr(caller.ndb._menutree,"olc_prototype"):
+ prototype=caller.ndb._menutree.olc_prototype
+ ifnotprototype:
+ caller.ndb._menutree.olc_prototype=prototype={}
+ caller.ndb._menutree.olc_new=True
+ returnprototype
+
+
+def_get_flat_menu_prototype(caller,refresh=False,validate=False):
+ """Return prototype where parent values are included"""
+ flat_prototype=None
+ ifnotrefreshandhasattr(caller.ndb._menutree,"olc_flat_prototype"):
+ flat_prototype=caller.ndb._menutree.olc_flat_prototype
+ ifnotflat_prototype:
+ prot=_get_menu_prototype(caller)
+ caller.ndb._menutree.olc_flat_prototype=flat_prototype=spawner.flatten_prototype(
+ prot,validate=validate
+ )
+ returnflat_prototype
+
+
+def_get_unchanged_inherited(caller,protname):
+ """Return prototype values inherited from parent(s), which are not replaced in child"""
+ prototype=_get_menu_prototype(caller)
+ ifprotnameinprototype:
+ returnprotname[protname],False
+ else:
+ flattened=_get_flat_menu_prototype(caller)
+ ifprotnameinflattened:
+ returnprotname[protname],True
+ returnNone,False
+
+
+def_set_menu_prototype(caller,prototype):
+ """Set the prototype with existing one"""
+ caller.ndb._menutree.olc_prototype=prototype
+ caller.ndb._menutree.olc_new=False
+ returnprototype
+
+
+def_is_new_prototype(caller):
+ """Check if prototype is marked as new or was loaded from a saved one."""
+ returnhasattr(caller.ndb._menutree,"olc_new")
+
+
+def_format_option_value(prop,required=False,prototype=None,cropper=None):
+ """
+ Format wizard option values.
+
+ Args:
+ prop (str): Name or value to format.
+ required (bool, optional): The option is required.
+ prototype (dict, optional): If given, `prop` will be considered a key in this prototype.
+ cropper (callable, optional): A function to crop the value to a certain width.
+
+ Returns:
+ value (str): The formatted value.
+ """
+ ifprototypeisnotNone:
+ prop=prototype.get(prop,"")
+
+ out=prop
+ ifcallable(prop):
+ ifhasattr(prop,"__name__"):
+ out="<{}>".format(prop.__name__)
+ else:
+ out=repr(prop)
+ ifutils.is_iter(prop):
+ out=", ".join(str(pr)forprinprop)
+ ifnotoutandrequired:
+ out="|runset"
+ ifout:
+ return" ({}|n)".format(cropper(out)ifcropperelseutils.crop(out,_MENU_CROP_WIDTH))
+ return""
+
+
+def_set_prototype_value(caller,field,value,parse=True):
+ """Set prototype's field in a safe way."""
+ prototype=_get_menu_prototype(caller)
+ prototype[field]=value
+ caller.ndb._menutree.olc_prototype=prototype
+ returnprototype
+
+
+def_set_property(caller,raw_string,**kwargs):
+ """
+ Add or update a property. To be called by the 'goto' option variable.
+
+ Args:
+ caller (Object, Account): The user of the wizard.
+ raw_string (str): Input from user on given node - the new value to set.
+
+ Keyword Args:
+ test_parse (bool): If set (default True), parse raw_string for protfuncs and obj-refs and
+ try to run result through literal_eval. The parser will be run in 'testing' mode and any
+ parsing errors will shown to the user. Note that this is just for testing, the original
+ given string will be what is inserted.
+ prop (str): Property name to edit with `raw_string`.
+ processor (callable): Converts `raw_string` to a form suitable for saving.
+ next_node (str): Where to redirect to after this has run.
+
+ Returns:
+ next_node (str): Next node to go to.
+
+ """
+ prop=kwargs.get("prop","prototype_key")
+ processor=kwargs.get("processor",None)
+ next_node=kwargs.get("next_node",None)
+
+ ifcallable(processor):
+ try:
+ value=processor(raw_string)
+ exceptExceptionaserr:
+ caller.msg(
+ "Could not set {prop} to {value} ({err})".format(
+ prop=prop.replace("_","-").capitalize(),value=raw_string,err=str(err)
+ )
+ )
+ # this means we'll re-run the current node.
+ returnNone
+ else:
+ value=raw_string
+
+ ifnotvalue:
+ returnnext_node
+
+ prototype=_set_prototype_value(caller,prop,value)
+ caller.ndb._menutree.olc_prototype=prototype
+
+ try:
+ # TODO simple way to get rid of the u'' markers in list reprs, remove this when on py3.
+ repr_value=json.dumps(value)
+ exceptException:
+ repr_value=value
+
+ out=[" Set {prop} to {value} ({typ}).".format(prop=prop,value=repr_value,typ=type(value))]
+
+ ifkwargs.get("test_parse",True):
+ out.append(" Simulating prototype-func parsing ...")
+ err,parsed_value=protlib.protfunc_parser(value,testing=True)
+ iferr:
+ out.append(" |yPython `literal_eval` warning: {}|n".format(err))
+ ifparsed_value!=value:
+ out.append(
+ " |g(Example-)value when parsed ({}):|n {}".format(type(parsed_value),parsed_value)
+ )
+ else:
+ out.append(" |gNo change when parsed.")
+
+ caller.msg("\n".join(out))
+
+ returnnext_node
+
+
+def_wizard_options(curr_node,prev_node,next_node,color="|W",search=False):
+ """Creates default navigation options available in the wizard."""
+ options=[]
+ ifprev_node:
+ options.append(
+ {
+ "key":("|wB|Wack","b"),
+ "desc":"{color}({node})|n".format(color=color,node=prev_node.replace("_","-")),
+ "goto":"node_{}".format(prev_node),
+ }
+ )
+ ifnext_node:
+ options.append(
+ {
+ "key":("|wF|Worward","f"),
+ "desc":"{color}({node})|n".format(color=color,node=next_node.replace("_","-")),
+ "goto":"node_{}".format(next_node),
+ }
+ )
+
+ options.append({"key":("|wI|Wndex","i"),"goto":"node_index"})
+
+ ifcurr_node:
+ options.append(
+ {
+ "key":("|wV|Walidate prototype","validate","v"),
+ "goto":("node_validate_prototype",{"back":curr_node}),
+ }
+ )
+ ifsearch:
+ options.append(
+ {
+ "key":("|wSE|Warch objects","search object","search","se"),
+ "goto":("node_search_object",{"back":curr_node}),
+ }
+ )
+
+ returnoptions
+
+
+def_set_actioninfo(caller,string):
+ caller.ndb._menutree.actioninfo=string
+
+
+def_path_cropper(pythonpath):
+ "Crop path to only the last component"
+ returnpythonpath.split(".")[-1]
+
+
+def_validate_prototype(prototype):
+ """Run validation on prototype"""
+
+ txt=protlib.prototype_to_str(prototype)
+ errors="\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)"
+ err=False
+ try:
+ # validate, don't spawn
+ spawner.spawn(prototype,only_validate=True)
+ exceptRuntimeErrorasexc:
+ errors="\n\n|r{}|n".format(exc)
+ err=True
+ exceptRuntimeWarningasexc:
+ errors="\n\n|y{}|n".format(exc)
+ err=True
+
+ text=txt+errors
+ returnerr,text
+
+
+def_format_protfuncs():
+ out=[]
+ sorted_funcs=[
+ (key,func)forkey,funcinsorted(protlib.PROT_FUNCS.items(),key=lambdatup:tup[0])
+ ]
+ forprotfunc_name,protfuncinsorted_funcs:
+ out.append(
+ "- |c${name}|n - |W{docs}".format(
+ name=protfunc_name,
+ docs=utils.justify(protfunc.__doc__.strip(),align="l",indent=10).strip(),
+ )
+ )
+ return"\n ".join(out)
+
+
+def_format_lockfuncs():
+ out=[]
+ sorted_funcs=[
+ (key,func)forkey,funcinsorted(get_all_lockfuncs().items(),key=lambdatup:tup[0])
+ ]
+ forlockfunc_name,lockfuncinsorted_funcs:
+ doc=(lockfunc.__doc__or"").strip()
+ out.append(
+ "- |c${name}|n - |W{docs}".format(
+ name=lockfunc_name,docs=utils.justify(doc,align="l",indent=10).strip()
+ )
+ )
+ return"\n".join(out)
+
+
+def_format_list_actions(*args,**kwargs):
+ """Create footer text for nodes with extra list actions
+
+ Args:
+ actions (str): Available actions. The first letter of the action name will be assumed
+ to be a shortcut.
+ Keyword Args:
+ prefix (str): Default prefix to use.
+ Returns:
+ string (str): Formatted footer for adding to the node text.
+
+ """
+ actions=[]
+ prefix=kwargs.get("prefix","|WSelect with |w<num>|W. Other actions:|n ")
+ foractioninargs:
+ actions.append("|w{}|n|W{} |w<num>|n".format(action[0],action[1:]))
+ returnprefix+" |W|||n ".join(actions)
+
+
+def_get_current_value(caller,keyname,comparer=None,formatter=str,only_inherit=False):
+ """
+ Return current value, marking if value comes from parent or set in this prototype.
+
+ Args:
+ keyname (str): Name of prototoype key to get current value of.
+ comparer (callable, optional): This will be called as comparer(prototype_value,
+ flattened_value) and is expected to return the value to show as the current
+ or inherited one. If not given, a straight comparison is used and what is returned
+ depends on the only_inherit setting.
+ formatter (callable, optional)): This will be called with the result of comparer.
+ only_inherit (bool, optional): If a current value should only be shown if all
+ the values are inherited from the prototype parent (otherwise, show an empty string).
+ Returns:
+ current (str): The current value.
+
+ """
+
+ def_default_comparer(protval,flatval):
+ ifonly_inherit:
+ return""ifprotvalelseflatval
+ else:
+ returnprotvalifprotvalelseflatval
+
+ ifnotcallable(comparer):
+ comparer=_default_comparer
+
+ prot=_get_menu_prototype(caller)
+ flat_prot=_get_flat_menu_prototype(caller)
+
+ out=""
+ ifkeynameinprot:
+ ifkeynameinflat_prot:
+ out=formatter(comparer(prot[keyname],flat_prot[keyname]))
+ ifonly_inherit:
+ ifstr(out).strip():
+ return"|WCurrent|n {} |W(|binherited|W):|n {}".format(keyname,out)
+ return""
+ else:
+ ifout:
+ return"|WCurrent|n {}|W:|n {}".format(keyname,out)
+ return"|W[No {} set]|n".format(keyname)
+ elifonly_inherit:
+ return""
+ else:
+ out=formatter(prot[keyname])
+ return"|WCurrent|n {}|W:|n {}".format(keyname,out)
+ elifkeynameinflat_prot:
+ out=formatter(flat_prot[keyname])
+ ifout:
+ return"|WCurrent|n {} |W(|n|binherited|W):|n {}".format(keyname,out)
+ else:
+ return""
+ elifonly_inherit:
+ return""
+ else:
+ return"|W[No {} set]|n".format(keyname)
+
+
+def_default_parse(raw_inp,choices,*args):
+ """
+ Helper to parse default input to a node decorated with the node_list decorator on
+ the form l1, l 2, look 1, etc. Spaces are ignored, as is case.
+
+ Args:
+ raw_inp (str): Input from the user.
+ choices (list): List of available options on the node listing (list of strings).
+ args (tuples): The available actions, each specifed as a tuple (name, alias, ...)
+ Returns:
+ choice (str): A choice among the choices, or None if no match was found.
+ action (str): The action operating on the choice, or None.
+
+ """
+ raw_inp=raw_inp.lower().strip()
+ mapping={t.lower():tup[0]fortupinargsfortintup}
+ match=re.match(r"(%s)\s*?(\d+)$"%"|".join(mapping.keys()),raw_inp)
+ ifmatch:
+ action=mapping.get(match.group(1),None)
+ num=int(match.group(2))-1
+ num=numif0<=num<len(choices)elseNone
+ ifactionisnotNoneandnumisnotNone:
+ returnchoices[num],action
+ returnNone,None
+
+
+# Menu nodes ------------------------------
+
+# helper nodes
+
+# validate prototype (available as option from all nodes)
+
+
+
[docs]defnode_validate_prototype(caller,raw_string,**kwargs):
+ """General node to view and validate a protototype"""
+ prototype=_get_flat_menu_prototype(caller,refresh=True,validate=False)
+ prev_node=kwargs.get("back","index")
+
+ _,text=_validate_prototype(prototype)
+
+ helptext="""
+ The validator checks if the prototype's various values are on the expected form. It also tests
+ any $protfuncs.
+
+ """
+
+ text=(text,helptext)
+
+ options=_wizard_options(None,prev_node,None)
+ options.append({"key":"_default","goto":"node_"+prev_node})
+
+ returntext,options
+
+
+# node examine_entity
+
+
+
[docs]defnode_examine_entity(caller,raw_string,**kwargs):
+ """
+ General node to view a text and then return to previous node. Kwargs should contain "text" for
+ the text to show and 'back" pointing to the node to return to.
+ """
+ text=kwargs.get("text","Nothing was found here.")
+ helptext="Use |wback|n to return to the previous node."
+ prev_node=kwargs.get("back","index")
+
+ text=(text,helptext)
+
+ options=_wizard_options(None,prev_node,None)
+ options.append({"key":"_default","goto":"node_"+prev_node})
+
+ returntext,options
+
+
+# node object_search
+
+
+def_search_object(caller):
+ "update search term based on query stored on menu; store match too"
+ try:
+ searchstring=caller.ndb._menutree.olc_search_object_term.strip()
+ caller.ndb._menutree.olc_search_object_matches=[]
+ exceptAttributeError:
+ return[]
+
+ ifnotsearchstring:
+ caller.msg("Must specify a search criterion.")
+ return[]
+
+ is_dbref=utils.dbref(searchstring)
+ is_account=searchstring.startswith("*")
+
+ ifis_dbreforis_account:
+
+ ifis_dbref:
+ # a dbref search
+ results=caller.search(searchstring,global_search=True,quiet=True)
+ else:
+ # an account search
+ searchstring=searchstring.lstrip("*")
+ results=caller.search_account(searchstring,quiet=True)
+ else:
+ keyquery=Q(db_key__istartswith=searchstring)
+ aliasquery=Q(
+ db_tags__db_key__istartswith=searchstring,db_tags__db_tagtype__iexact="alias"
+ )
+ results=ObjectDB.objects.filter(keyquery|aliasquery).distinct()
+
+ caller.msg("Searching for '{}' ...".format(searchstring))
+ caller.ndb._menutree.olc_search_object_matches=results
+ return["{}(#{})".format(obj.key,obj.id)forobjinresults]
+
+
+def_object_search_select(caller,obj_entry,**kwargs):
+ choices=kwargs["available_choices"]
+ num=choices.index(obj_entry)
+ matches=caller.ndb._menutree.olc_search_object_matches
+ obj=matches[num]
+
+ ifnotobj.access(caller,"examine"):
+ caller.msg("|rYou don't have 'examine' access on this object.|n")
+ delcaller.ndb._menutree.olc_search_object_term
+ return"node_search_object"
+
+ prot=spawner.prototype_from_object(obj)
+ txt=protlib.prototype_to_str(prot)
+ return"node_examine_entity",{"text":txt,"back":"search_object"}
+
+
+def_object_search_actions(caller,raw_inp,**kwargs):
+ "All this does is to queue a search query"
+ choices=kwargs["available_choices"]
+ obj_entry,action=_default_parse(
+ raw_inp,choices,("examine","e"),("create prototype from object","create","c")
+ )
+
+ raw_inp=raw_inp.strip()
+
+ ifobj_entry:
+
+ num=choices.index(obj_entry)
+ matches=caller.ndb._menutree.olc_search_object_matches
+ obj=matches[num]
+ prot=spawner.prototype_from_object(obj)
+
+ ifaction=="examine":
+
+ ifnotobj.access(caller,"examine"):
+ caller.msg("\n|rYou don't have 'examine' access on this object.|n")
+ delcaller.ndb._menutree.olc_search_object_term
+ return"node_search_object"
+
+ txt=protlib.prototype_to_str(prot)
+ return"node_examine_entity",{"text":txt,"back":"search_object"}
+ else:
+ # load prototype
+
+ ifnotobj.access(caller,"edit"):
+ caller.msg("|rYou don't have access to do this with this object.|n")
+ delcaller.ndb._menutree.olc_search_object_term
+ return"node_search_object"
+
+ _set_menu_prototype(caller,prot)
+ caller.msg("Created prototype from object.")
+ return"node_index"
+ elifraw_inp:
+ caller.ndb._menutree.olc_search_object_term=raw_inp
+ return"node_search_object",kwargs
+ else:
+ # empty input - exit back to previous node
+ prev_node="node_"+kwargs.get("back","index")
+ returnprev_node
+
+
+@list_node(_search_object,_object_search_select)
+defnode_search_object(caller,raw_inp,**kwargs):
+ """
+ Node for searching for an existing object.
+ """
+ try:
+ matches=caller.ndb._menutree.olc_search_object_matches
+ exceptAttributeError:
+ matches=[]
+ nmatches=len(matches)
+ prev_node=kwargs.get("back","index")
+
+ ifmatches:
+ text="""
+ Found {num} match{post}.
+
+ (|RWarning: creating a prototype will |roverwrite|r |Rthe current prototype!)|n""".format(
+ num=nmatches,post="es"ifnmatches>1else""
+ )
+ _set_actioninfo(
+ caller,
+ _format_list_actions("examine","create prototype from object",prefix="Actions: "),
+ )
+ else:
+ text="Enter search criterion."
+
+ helptext="""
+ You can search objects by specifying partial key, alias or its exact #dbref. Use *query to
+ search for an Account instead.
+
+ Once having found any matches you can choose to examine it or use |ccreate prototype from
+ object|n. If doing the latter, a prototype will be calculated from the selected object and
+ loaded as the new 'current' prototype. This is useful for having a base to build from but be
+ careful you are not throwing away any existing, unsaved, prototype work!
+ """
+
+ text=(text,helptext)
+
+ options=_wizard_options(None,prev_node,None)
+ options.append({"key":"_default","goto":(_object_search_actions,{"back":prev_node})})
+
+ returntext,options
+
+
+# main index (start page) node
+
+
+
[docs]defnode_index(caller):
+ prototype=_get_menu_prototype(caller)
+
+ text="""
+ |c --- Prototype wizard --- |n
+%s
+
+ A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype
+ can either be hard-coded, left empty or scripted using |w$protfuncs|n - for example to
+ randomize the value every time a new entity is spawned. The fields whose names start with
+ 'Prototype-' are not fields on the object itself but are used for prototype-inheritance, or
+ when saving and loading.
+
+ Select prototype field to edit. If you are unsure, start from [|w1|n]. Enter [|wh|n]elp at
+ any menu node for more info.
+
+ """
+ helptxt="""
+ |c- prototypes |n
+
+ A prototype is really just a Python dictionary. When spawning, this dictionary is essentially
+ passed into `|wevennia.utils.create.create_object(**prototype)|n` to create a new object. By
+ using different prototypes you can customize instances of objects without having to do code
+ changes to their typeclass (something which requires code access). The classical example is
+ to spawn goblins with different names, looks, equipment and skill, each based on the same
+ `Goblin` typeclass.
+
+ At any time you can [|wV|n]alidate that the prototype works correctly and use it to
+ [|wSP|n]awn a new entity. You can also [|wSA|n]ve|n your work, [|wLO|n]oad an existing
+ prototype to [|wSE|n]arch for existing objects to use as a base. Use [|wL|n]ook to re-show a
+ menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will show context-sensitive
+ help.
+
+
+ |c- $protfuncs |n
+
+ Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are
+ entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n
+ only. They can also be nested for combined effects.
+
+{pfuncs}
+ """.format(
+ pfuncs=_format_protfuncs()
+ )
+
+ # If a prototype is being edited, show its key and
+ # prototype_key under the title
+ loaded_prototype=""
+ if"prototype_key"inprototypeor"key"inprototype:
+ loaded_prototype=" --- Editing: |y{}({})|n --- ".format(
+ prototype.get("key",""),prototype.get("prototype_key","")
+ )
+ text=text%(loaded_prototype)
+
+ text=(text,helptxt)
+
+ options=[]
+ options.append(
+ {
+ "desc":"|WPrototype-Key|n|n{}".format(
+ _format_option_value("Key","prototype_key"notinprototype,prototype,None)
+ ),
+ "goto":"node_prototype_key",
+ }
+ )
+ forkeyin(
+ "Prototype_Parent",
+ "Typeclass",
+ "Key",
+ "Aliases",
+ "Attrs",
+ "Tags",
+ "Locks",
+ "Permissions",
+ "Location",
+ "Home",
+ "Destination",
+ ):
+ required=False
+ cropper=None
+ ifkeyin("Prototype_Parent","Typeclass"):
+ required=("prototype_parent"notinprototype)and("typeclass"notinprototype)
+ ifkey=="Typeclass":
+ cropper=_path_cropper
+ options.append(
+ {
+ "desc":"{}{}|n{}".format(
+ "|W"ifkey=="Prototype_Parent"else"|w",
+ key.replace("_","-"),
+ _format_option_value(key,required,prototype,cropper=cropper),
+ ),
+ "goto":"node_{}".format(key.lower()),
+ }
+ )
+ required=False
+ forkeyin("Desc","Tags","Locks"):
+ options.append(
+ {
+ "desc":"|WPrototype-{}|n|n{}".format(
+ key,_format_option_value(key,required,prototype,None)
+ ),
+ "goto":"node_prototype_{}".format(key.lower()),
+ }
+ )
+
+ options.extend(
+ (
+ {"key":("|wV|Walidate prototype","validate","v"),"goto":"node_validate_prototype"},
+ {"key":("|wSA|Wve prototype","save","sa"),"goto":"node_prototype_save"},
+ {"key":("|wSP|Wawn prototype","spawn","sp"),"goto":"node_prototype_spawn"},
+ {"key":("|wLO|Wad prototype","load","lo"),"goto":"node_prototype_load"},
+ {"key":("|wSE|Warch objects|n","search","se"),"goto":"node_search_object"},
+ )
+ )
+
+ returntext,options
+
+
+# prototype_key node
+
+
+def_check_prototype_key(caller,key):
+ old_prototype=protlib.search_prototype(key)
+ olc_new=_is_new_prototype(caller)
+ key=key.strip().lower()
+ ifold_prototype:
+ old_prototype=old_prototype[0]
+ # we are starting a new prototype that matches an existing
+ ifnotcaller.locks.check_lockstring(
+ caller,old_prototype["prototype_locks"],access_type="edit"
+ ):
+ # return to the node_prototype_key to try another key
+ caller.msg(
+ "Prototype '{key}' already exists and you don't "
+ "have permission to edit it.".format(key=key)
+ )
+ return"node_prototype_key"
+ elifolc_new:
+ # we are selecting an existing prototype to edit. Reset to index.
+ delcaller.ndb._menutree.olc_new
+ caller.ndb._menutree.olc_prototype=old_prototype
+ caller.msg("Prototype already exists. Reloading.")
+ return"node_index"
+
+ return_set_property(caller,key,prop="prototype_key")
+
+
+
[docs]defnode_prototype_key(caller):
+
+ text="""
+ The |cPrototype-Key|n uniquely identifies the prototype and is |wmandatory|n. It is used to
+ find and use the prototype to spawn new entities. It is not case sensitive.
+
+ (To set a new value, just write it and press enter)
+
+{current}""".format(
+ current=_get_current_value(caller,"prototype_key")
+ )
+
+ helptext="""
+ The prototype-key is not itself used when spawnng the new object, but is only used for
+ managing, storing and loading the prototype. It must be globally unique, so existing keys
+ will be checked before a new key is accepted. If an existing key is picked, the existing
+ prototype will be loaded.
+ """
+
+ options=_wizard_options("prototype_key","index","prototype_parent")
+ options.append({"key":"_default","goto":_check_prototype_key})
+
+ text=(text,helptext)
+ returntext,options
+
+
+# prototype_parents node
+
+
+def_all_prototype_parents(caller):
+ """Return prototype_key of all available prototypes for listing in menu"""
+ return[
+ prototype["prototype_key"]
+ forprototypeinprotlib.search_prototype()
+ if"prototype_key"inprototype
+ ]
+
+
+def_prototype_parent_actions(caller,raw_inp,**kwargs):
+ """Parse the default Convert prototype to a string representation for closer inspection"""
+ choices=kwargs.get("available_choices",[])
+ prototype_parent,action=_default_parse(
+ raw_inp,choices,("examine","e","l"),("add","a"),("remove","r","delete","d")
+ )
+
+ ifprototype_parent:
+ # a selection of parent was made
+ prototype_parent=protlib.search_prototype(key=prototype_parent)[0]
+ prototype_parent_key=prototype_parent["prototype_key"]
+
+ # which action to apply on the selection
+ ifaction=="examine":
+ # examine the prototype
+ txt=protlib.prototype_to_str(prototype_parent)
+ kwargs["text"]=txt
+ kwargs["back"]="prototype_parent"
+ return"node_examine_entity",kwargs
+ elifaction=="add":
+ # add/append parent
+ prot=_get_menu_prototype(caller)
+ current_prot_parent=prot.get("prototype_parent",None)
+ ifcurrent_prot_parent:
+ current_prot_parent=utils.make_iter(current_prot_parent)
+ ifprototype_parent_keyincurrent_prot_parent:
+ caller.msg("Prototype_parent {} is already used.".format(prototype_parent_key))
+ return"node_prototype_parent"
+ else:
+ current_prot_parent.append(prototype_parent_key)
+ caller.msg("Add prototype parent for multi-inheritance.")
+ else:
+ current_prot_parent=prototype_parent_key
+ try:
+ ifprototype_parent:
+ spawner.flatten_prototype(prototype_parent,validate=True)
+ else:
+ raiseRuntimeError("Not found.")
+ exceptRuntimeErroraserr:
+ caller.msg(
+ "Selected prototype-parent {} "
+ "caused Error(s):\n|r{}|n".format(prototype_parent,err)
+ )
+ return"node_prototype_parent"
+ _set_prototype_value(caller,"prototype_parent",current_prot_parent)
+ _get_flat_menu_prototype(caller,refresh=True)
+ elifaction=="remove":
+ # remove prototype parent
+ prot=_get_menu_prototype(caller)
+ current_prot_parent=prot.get("prototype_parent",None)
+ ifcurrent_prot_parent:
+ current_prot_parent=utils.make_iter(current_prot_parent)
+ try:
+ current_prot_parent.remove(prototype_parent_key)
+ _set_prototype_value(caller,"prototype_parent",current_prot_parent)
+ _get_flat_menu_prototype(caller,refresh=True)
+ caller.msg("Removed prototype parent {}.".format(prototype_parent_key))
+ exceptValueError:
+ caller.msg(
+ "|rPrototype-parent {} could not be removed.".format(prototype_parent_key)
+ )
+ return"node_prototype_parent"
+
+
+def_prototype_parent_select(caller,new_parent):
+
+ ret=None
+ prototype_parent=protlib.search_prototype(new_parent)
+ try:
+ ifprototype_parent:
+ spawner.flatten_prototype(prototype_parent[0],validate=True)
+ else:
+ raiseRuntimeError("Not found.")
+ exceptRuntimeErroraserr:
+ caller.msg(
+ "Selected prototype-parent {} ""caused Error(s):\n|r{}|n".format(new_parent,err)
+ )
+ else:
+ ret=_set_property(
+ caller,
+ new_parent,
+ prop="prototype_parent",
+ processor=str,
+ next_node="node_prototype_parent",
+ )
+ _get_flat_menu_prototype(caller,refresh=True)
+ caller.msg("Selected prototype parent |c{}|n.".format(new_parent))
+ returnret
+
+
+@list_node(_all_prototype_parents,_prototype_parent_select)
+defnode_prototype_parent(caller):
+ prototype=_get_menu_prototype(caller)
+
+ prot_parent_keys=prototype.get("prototype_parent")
+
+ text="""
+ The |cPrototype Parent|n allows you to |winherit|n prototype values from another named
+ prototype (given as that prototype's |wprototype_key|n). If not changing these values in
+ the current prototype, the parent's value will be used. Pick the available prototypes below.
+
+ Note that somewhere in the prototype's parentage, a |ctypeclass|n must be specified. If no
+ parent is given, this prototype must define the typeclass (next menu node).
+
+{current}
+ """
+ helptext="""
+ Prototypes can inherit from one another. Changes in the child replace any values set in a
+ parent. The |wtypeclass|n key must exist |wsomewhere|n in the parent chain for the
+ prototype to be valid.
+ """
+
+ _set_actioninfo(caller,_format_list_actions("examine","add","remove"))
+
+ ptexts=[]
+ ifprot_parent_keys:
+ forpkeyinutils.make_iter(prot_parent_keys):
+ prot_parent=protlib.search_prototype(pkey)
+ ifprot_parent:
+ prot_parent=prot_parent[0]
+ ptexts.append(
+ "|c -- {pkey} -- |n\n{prot}".format(
+ pkey=pkey,prot=protlib.prototype_to_str(prot_parent)
+ )
+ )
+ else:
+ ptexts.append("Prototype parent |r{pkey} was not found.".format(pkey=pkey))
+
+ ifnotptexts:
+ ptexts.append("[No prototype_parent set]")
+
+ text=text.format(current="\n\n".join(ptexts))
+
+ text=(text,helptext)
+
+ options=_wizard_options("prototype_parent","prototype_key","typeclass",color="|W")
+ options.append({"key":"_default","goto":_prototype_parent_actions})
+
+ returntext,options
+
+
+# typeclasses node
+
+
+def_all_typeclasses(caller):
+ """Get name of available typeclasses."""
+ returnlist(
+ name
+ fornameinsorted(utils.get_all_typeclasses("evennia.objects.models.ObjectDB").keys())
+ ifname!="evennia.objects.models.ObjectDB"
+ )
+
+
+def_typeclass_actions(caller,raw_inp,**kwargs):
+ """Parse actions for typeclass listing"""
+
+ choices=kwargs.get("available_choices",[])
+ typeclass_path,action=_default_parse(
+ raw_inp,choices,("examine","e","l"),("remove","r","delete","d")
+ )
+
+ iftypeclass_path:
+ ifaction=="examine":
+ typeclass=utils.get_all_typeclasses().get(typeclass_path)
+ iftypeclass:
+ docstr=[]
+ forlineintypeclass.__doc__.split("\n"):
+ ifline.strip():
+ docstr.append(line)
+ elifdocstr:
+ break
+ docstr="\n".join(docstr)ifdocstrelse"<empty>"
+ txt=(
+ "Typeclass |c{typeclass_path}|n; "
+ "First paragraph of docstring:\n\n{docstring}".format(
+ typeclass_path=typeclass_path,docstring=docstr
+ )
+ )
+ else:
+ txt="This is typeclass |y{}|n.".format(typeclass)
+ return"node_examine_entity",{"text":txt,"back":"typeclass"}
+ elifaction=="remove":
+ prototype=_get_menu_prototype(caller)
+ old_typeclass=prototype.pop("typeclass",None)
+ ifold_typeclass:
+ _set_menu_prototype(caller,prototype)
+ caller.msg("Cleared typeclass {}.".format(old_typeclass))
+ else:
+ caller.msg("No typeclass to remove.")
+ return"node_typeclass"
+
+
+def_typeclass_select(caller,typeclass):
+ """Select typeclass from list and add it to prototype. Return next node to go to."""
+ ret=_set_property(caller,typeclass,prop="typeclass",processor=str)
+ caller.msg("Selected typeclass |c{}|n.".format(typeclass))
+ returnret
+
+
+@list_node(_all_typeclasses,_typeclass_select)
+defnode_typeclass(caller):
+ text="""
+ The |cTypeclass|n defines what 'type' of object this is - the actual working code to use.
+
+ All spawned objects must have a typeclass. If not given here, the typeclass must be set in
+ one of the prototype's |cparents|n.
+
+{current}
+ """.format(
+ current=_get_current_value(caller,"typeclass"),
+ actions="|WSelect with |w<num>|W. Other actions: "
+ "|we|Wxamine |w<num>|W, |wr|Wemove selection",
+ )
+
+ helptext="""
+ A |nTypeclass|n is specified by the actual python-path to the class definition in the
+ Evennia code structure.
+
+ Which |cAttributes|n, |cLocks|n and other properties have special
+ effects or expects certain values depend greatly on the code in play.
+ """
+
+ text=(text,helptext)
+
+ options=_wizard_options("typeclass","prototype_parent","key",color="|W")
+ options.append({"key":"_default","goto":_typeclass_actions})
+ returntext,options
+
+
+# key node
+
+
+
[docs]defnode_key(caller):
+ text="""
+ The |cKey|n is the given name of the object to spawn. This will retain the given case.
+
+{current}
+ """.format(
+ current=_get_current_value(caller,"key")
+ )
+
+ helptext="""
+ The key should often not be identical for every spawned object. Using a randomising
+ $protfunc can be used, for example |c$choice(Alan, Tom, John)|n will give one of the three
+ names every time an object of this prototype is spawned.
+
+ |c$protfuncs|n
+{pfuncs}
+ """.format(
+ pfuncs=_format_protfuncs()
+ )
+
+ text=(text,helptext)
+
+ options=_wizard_options("key","typeclass","aliases")
+ options.append(
+ {
+ "key":"_default",
+ "goto":(_set_property,dict(prop="key",processor=lambdas:s.strip())),
+ }
+ )
+ returntext,options
+
+
+# aliases node
+
+
+def_all_aliases(caller):
+ "Get aliases in prototype"
+ prototype=_get_menu_prototype(caller)
+ returnprototype.get("aliases",[])
+
+
+def_aliases_select(caller,alias):
+ "Add numbers as aliases"
+ aliases=_all_aliases(caller)
+ try:
+ ind=str(aliases.index(alias)+1)
+ ifindnotinaliases:
+ aliases.append(ind)
+ _set_prototype_value(caller,"aliases",aliases)
+ caller.msg("Added alias '{}'.".format(ind))
+ except(IndexError,ValueError)aserr:
+ caller.msg("Error: {}".format(err))
+
+ return"node_aliases"
+
+
+def_aliases_actions(caller,raw_inp,**kwargs):
+ """Parse actions for aliases listing"""
+ choices=kwargs.get("available_choices",[])
+ alias,action=_default_parse(raw_inp,choices,("remove","r","delete","d"))
+
+ aliases=_all_aliases(caller)
+ ifaliasandaction=="remove":
+ try:
+ aliases.remove(alias)
+ _set_prototype_value(caller,"aliases",aliases)
+ caller.msg("Removed alias '{}'.".format(alias))
+ exceptValueError:
+ caller.msg("No matching alias found to remove.")
+ else:
+ # if not a valid remove, add as a new alias
+ alias=raw_inp.lower().strip()
+ ifaliasandaliasnotinaliases:
+ aliases.append(alias)
+ _set_prototype_value(caller,"aliases",aliases)
+ caller.msg("Added alias '{}'.".format(alias))
+ else:
+ caller.msg("Alias '{}' was already set.".format(alias))
+ return"node_aliases"
+
+
+@list_node(_all_aliases,_aliases_select)
+defnode_aliases(caller):
+
+ text="""
+ |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not
+ case sensitive.
+
+{current}
+ """.format(
+ current=_get_current_value(
+ caller,
+ "aliases",
+ comparer=lambdapropval,flatval:[alforalinflatvalifalnotinpropval],
+ formatter=lambdalst:"\n"+", ".join(lst),
+ only_inherit=True,
+ )
+ )
+ _set_actioninfo(
+ caller,_format_list_actions("remove",prefix="|w<text>|W to add new alias. Other action: ")
+ )
+
+ helptext="""
+ Aliases are fixed alternative identifiers and are stored with the new object.
+
+ |c$protfuncs|n
+
+{pfuncs}
+ """.format(
+ pfuncs=_format_protfuncs()
+ )
+
+ text=(text,helptext)
+
+ options=_wizard_options("aliases","key","attrs")
+ options.append({"key":"_default","goto":_aliases_actions})
+ returntext,options
+
+
+# attributes node
+
+
+def_caller_attrs(caller):
+ prototype=_get_menu_prototype(caller)
+ attrs=[
+ "{}={}".format(tup[0],utils.crop(utils.to_str(tup[1]),width=10))
+ fortupinprototype.get("attrs",[])
+ ]
+ returnattrs
+
+
+def_get_tup_by_attrname(caller,attrname):
+ prototype=_get_menu_prototype(caller)
+ attrs=prototype.get("attrs",[])
+ try:
+ inp=[tup[0]fortupinattrs].index(attrname)
+ returnattrs[inp]
+ exceptValueError:
+ returnNone
+
+
+def_display_attribute(attr_tuple):
+ """Pretty-print attribute tuple"""
+ attrkey,value,category,locks=attr_tuple
+ value=protlib.protfunc_parser(value)
+ typ=type(value)
+ out="{attrkey} |c=|n {value} |W({typ}{category}{locks})|n".format(
+ attrkey=attrkey,
+ value=value,
+ typ=typ,
+ category=", category={}".format(category)ifcategoryelse"",
+ locks=", locks={}".format(";".join(locks))ifany(locks)else"",
+ )
+
+ returnout
+
+
+def_add_attr(caller,attr_string,**kwargs):
+ """
+ Add new attribute, parsing input.
+
+ Args:
+ caller (Object): Caller of menu.
+ attr_string (str): Input from user
+ attr is entered on these forms
+ attr = value
+ attr;category = value
+ attr;category;lockstring = value
+ Keyword Args:
+ delete (str): If this is set, attr_string is
+ considered the name of the attribute to delete and
+ no further parsing happens.
+ Returns:
+ result (str): Result string of action.
+ """
+ attrname=""
+ value=""
+ category=None
+ locks=""
+
+ if"delete"inkwargs:
+ attrname=attr_string.lower().strip()
+ elif"="inattr_string:
+ attrname,value=(part.strip()forpartinattr_string.split("=",1))
+ attrname=attrname.lower()
+ nameparts=attrname.split(";",2)
+ nparts=len(nameparts)
+ ifnparts==2:
+ attrname,category=nameparts
+ elifnparts>2:
+ attrname,category,locks=nameparts
+ attr_tuple=(attrname,value,category,str(locks))
+
+ ifattrname:
+ prot=_get_menu_prototype(caller)
+ attrs=prot.get("attrs",[])
+
+ if"delete"inkwargs:
+ try:
+ ind=[tup[0]fortupinattrs].index(attrname)
+ delattrs[ind]
+ _set_prototype_value(caller,"attrs",attrs)
+ return"Removed Attribute '{}'".format(attrname)
+ exceptIndexError:
+ return"Attribute to delete not found."
+
+ try:
+ # replace existing attribute with the same name in the prototype
+ ind=[tup[0]fortupinattrs].index(attrname)
+ attrs[ind]=attr_tuple
+ text="Edited Attribute '{}'.".format(attrname)
+ exceptValueError:
+ attrs.append(attr_tuple)
+ text="Added Attribute "+_display_attribute(attr_tuple)
+
+ _set_prototype_value(caller,"attrs",attrs)
+ else:
+ text="Attribute must be given as 'attrname[;category;locks] = <value>'."
+
+ returntext
+
+
+def_attr_select(caller,attrstr):
+ attrname,_=attrstr.split("=",1)
+ attrname=attrname.strip()
+
+ attr_tup=_get_tup_by_attrname(caller,attrname)
+ ifattr_tup:
+ return("node_examine_entity",{"text":_display_attribute(attr_tup),"back":"attrs"})
+ else:
+ caller.msg("Attribute not found.")
+ return"node_attrs"
+
+
+def_attrs_actions(caller,raw_inp,**kwargs):
+ """Parse actions for attribute listing"""
+ choices=kwargs.get("available_choices",[])
+ attrstr,action=_default_parse(
+ raw_inp,choices,("examine","e"),("remove","r","delete","d")
+ )
+ ifattrstrisNone:
+ attrstr=raw_inp
+ try:
+ attrname,_=attrstr.split("=",1)
+ exceptValueError:
+ caller.msg("|rNeed to enter the attribute on the form attrname=value.|n")
+ return"node_attrs"
+
+ attrname=attrname.strip()
+ attr_tup=_get_tup_by_attrname(caller,attrname)
+
+ ifactionandattr_tup:
+ ifaction=="examine":
+ return("node_examine_entity",{"text":_display_attribute(attr_tup),"back":"attrs"})
+ elifaction=="remove":
+ res=_add_attr(caller,attrname,delete=True)
+ caller.msg(res)
+ else:
+ res=_add_attr(caller,raw_inp)
+ caller.msg(res)
+ return"node_attrs"
+
+
+@list_node(_caller_attrs,_attr_select)
+defnode_attrs(caller):
+ def_currentcmp(propval,flatval):
+ "match by key + category"
+ cmp1=[(tup[0].lower(),tup[2].lower()iftup[2]elseNone)fortupinpropval]
+ return[
+ tup
+ fortupinflatval
+ if(tup[0].lower(),tup[2].lower()iftup[2]elseNone)notincmp1
+ ]
+
+ text="""
+ |cAttributes|n are custom properties of the object. Enter attributes on one of these forms:
+
+ attrname=value
+ attrname;category=value
+ attrname;category;lockstring=value
+
+ To give an attribute without a category but with a lockstring, leave that spot empty
+ (attrname;;lockstring=value). Attribute values can have embedded $protfuncs.
+
+{current}
+ """.format(
+ current=_get_current_value(
+ caller,
+ "attrs",
+ comparer=_currentcmp,
+ formatter=lambdalst:"\n"+"\n".join(_display_attribute(tup)fortupinlst),
+ only_inherit=True,
+ )
+ )
+ _set_actioninfo(caller,_format_list_actions("examine","remove",prefix="Actions: "))
+
+ helptext="""
+ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types
+ 'attredit' and 'attrread' are used to limit editing and viewing of the Attribute. Putting
+ the lock-type `attrcreate` in the |clocks|n prototype key can be used to restrict builders
+ from adding new Attributes.
+
+ |c$protfuncs
+
+{pfuncs}
+ """.format(
+ pfuncs=_format_protfuncs()
+ )
+
+ text=(text,helptext)
+
+ options=_wizard_options("attrs","aliases","tags")
+ options.append({"key":"_default","goto":_attrs_actions})
+ returntext,options
+
+
+# tags node
+
+
+def_caller_tags(caller):
+ prototype=_get_menu_prototype(caller)
+ tags=[tup[0]fortupinprototype.get("tags",[])]
+ returntags
+
+
+def_get_tup_by_tagname(caller,tagname):
+ prototype=_get_menu_prototype(caller)
+ tags=prototype.get("tags",[])
+ try:
+ inp=[tup[0]fortupintags].index(tagname)
+ returntags[inp]
+ exceptValueError:
+ returnNone
+
+
+def_display_tag(tag_tuple):
+ """Pretty-print tag tuple"""
+ tagkey,category,data=tag_tuple
+ out="Tag: '{tagkey}' (category: {category}{dat})".format(
+ tagkey=tagkey,category=category,dat=", data: {}".format(data)ifdataelse""
+ )
+ returnout
+
+
+def_add_tag(caller,tag_string,**kwargs):
+ """
+ Add tags to the system, parsing input
+
+ Args:
+ caller (Object): Caller of menu.
+ tag_string (str): Input from user on one of these forms
+ tagname
+ tagname;category
+ tagname;category;data
+
+ Keyword Args:
+ delete (str): If this is set, tag_string is considered
+ the name of the tag to delete.
+
+ Returns:
+ result (str): Result string of action.
+
+ """
+ tag=tag_string.strip().lower()
+ category=None
+ data=""
+
+ if"delete"inkwargs:
+ tag=tag_string.lower().strip()
+ else:
+ nameparts=tag.split(";",2)
+ ntuple=len(nameparts)
+ ifntuple==2:
+ tag,category=nameparts
+ elifntuple>2:
+ tag,category,data=nameparts[:3]
+
+ tag_tuple=(tag.lower(),category.lower()ifcategoryelseNone,data)
+
+ iftag:
+ prot=_get_menu_prototype(caller)
+ tags=prot.get("tags",[])
+
+ old_tag=_get_tup_by_tagname(caller,tag)
+
+ if"delete"inkwargs:
+
+ ifold_tag:
+ tags.pop(tags.index(old_tag))
+ text="Removed Tag '{}'.".format(tag)
+ else:
+ text="Found no Tag to remove."
+ elifnotold_tag:
+ # a fresh, new tag
+ tags.append(tag_tuple)
+ text="Added Tag '{}'".format(tag)
+ else:
+ # old tag exists; editing a tag means replacing old with new
+ ind=tags.index(old_tag)
+ tags[ind]=tag_tuple
+ text="Edited Tag '{}'".format(tag)
+
+ _set_prototype_value(caller,"tags",tags)
+ else:
+ text="Tag must be given as 'tag[;category;data]'."
+
+ returntext
+
+
+def_tag_select(caller,tagname):
+ tag_tup=_get_tup_by_tagname(caller,tagname)
+ iftag_tup:
+ return"node_examine_entity",{"text":_display_tag(tag_tup),"back":"attrs"}
+ else:
+ caller.msg("Tag not found.")
+ return"node_attrs"
+
+
+def_tags_actions(caller,raw_inp,**kwargs):
+ """Parse actions for tags listing"""
+ choices=kwargs.get("available_choices",[])
+ tagname,action=_default_parse(
+ raw_inp,choices,("examine","e"),("remove","r","delete","d")
+ )
+
+ iftagnameisNone:
+ tagname=raw_inp.lower().strip()
+
+ tag_tup=_get_tup_by_tagname(caller,tagname)
+
+ iftag_tup:
+ ifaction=="examine":
+ return("node_examine_entity",{"text":_display_tag(tag_tup),"back":"tags"})
+ elifaction=="remove":
+ res=_add_tag(caller,tagname,delete=True)
+ caller.msg(res)
+ else:
+ res=_add_tag(caller,raw_inp)
+ caller.msg(res)
+ return"node_tags"
+
+
+@list_node(_caller_tags,_tag_select)
+defnode_tags(caller):
+ def_currentcmp(propval,flatval):
+ "match by key + category"
+ cmp1=[(tup[0].lower(),tup[1].lower()iftup[2]elseNone)fortupinpropval]
+ return[
+ tup
+ fortupinflatval
+ if(tup[0].lower(),tup[1].lower()iftup[1]elseNone)notincmp1
+ ]
+
+ text="""
+ |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of
+ the following forms:
+ tagname
+ tagname;category
+ tagname;category;data
+
+{current}
+ """.format(
+ current=_get_current_value(
+ caller,
+ "tags",
+ comparer=_currentcmp,
+ formatter=lambdalst:"\n"+"\n".join(_display_tag(tup)fortupinlst),
+ only_inherit=True,
+ )
+ )
+ _set_actioninfo(caller,_format_list_actions("examine","remove",prefix="Actions: "))
+
+ helptext="""
+ Tags are shared between all objects with that tag. So the 'data' field (which is not
+ commonly used) can only hold eventual info about the Tag itself, not about the individual
+ object on which it sits.
+
+ All objects created with this prototype will automatically get assigned a tag named the same
+ as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to
+ optionally update previously spawned objects when their prototype changes.
+ """.format(
+ tag_category=protlib.PROTOTYPE_TAG_CATEGORY
+ )
+
+ text=(text,helptext)
+ options=_wizard_options("tags","attrs","locks")
+ options.append({"key":"_default","goto":_tags_actions})
+ returntext,options
+
+
+# locks node
+
+
+def_caller_locks(caller):
+ locks=_get_menu_prototype(caller).get("locks","")
+ return[lckforlckinlocks.split(";")iflck]
+
+
+def_locks_display(caller,lock):
+ returnlock
+
+
+def_lock_select(caller,lockstr):
+ return("node_examine_entity",{"text":_locks_display(caller,lockstr),"back":"locks"})
+
+
+def_lock_add(caller,lock,**kwargs):
+ locks=_caller_locks(caller)
+
+ try:
+ locktype,lockdef=lock.split(":",1)
+ exceptValueError:
+ return"Lockstring lacks ':'."
+
+ locktype=locktype.strip().lower()
+
+ if"delete"inkwargs:
+ try:
+ ind=locks.index(lock)
+ locks.pop(ind)
+ _set_prototype_value(caller,"locks",";".join(locks),parse=False)
+ ret="Lock {} deleted.".format(lock)
+ exceptValueError:
+ ret="No lock found to delete."
+ returnret
+ try:
+ locktypes=[lck.split(":",1)[0].strip().lower()forlckinlocks]
+ ind=locktypes.index(locktype)
+ locks[ind]=lock
+ ret="Lock with locktype '{}' updated.".format(locktype)
+ exceptValueError:
+ locks.append(lock)
+ ret="Added lock '{}'.".format(lock)
+ _set_prototype_value(caller,"locks",";".join(locks))
+ returnret
+
+
+def_locks_actions(caller,raw_inp,**kwargs):
+ choices=kwargs.get("available_choices",[])
+ lock,action=_default_parse(
+ raw_inp,choices,("examine","e"),("remove","r","delete","d")
+ )
+
+ iflock:
+ ifaction=="examine":
+ return("node_examine_entity",{"text":_locks_display(caller,lock),"back":"locks"})
+ elifaction=="remove":
+ ret=_lock_add(caller,lock,delete=True)
+ caller.msg(ret)
+ else:
+ ret=_lock_add(caller,raw_inp)
+ caller.msg(ret)
+
+ return"node_locks"
+
+
+@list_node(_caller_locks,_lock_select)
+defnode_locks(caller):
+ def_currentcmp(propval,flatval):
+ "match by locktype"
+ cmp1=[lck.split(":",1)[0]forlckinpropval.split(";")]
+ return";".join(lstrforlstrinflatval.split(";")iflstr.split(":",1)[0]notincmp1)
+
+ text="""
+ The |cLock string|n defines limitations for accessing various properties of the object once
+ it's spawned. The string should be on one of the following forms:
+
+ locktype:[NOT] lockfunc(args)
+ locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ...
+
+{current}{action}
+ """.format(
+ current=_get_current_value(
+ caller,
+ "locks",
+ comparer=_currentcmp,
+ formatter=lambdalockstr:"\n".join(
+ _locks_display(caller,lstr)forlstrinlockstr.split(";")
+ ),
+ only_inherit=True,
+ ),
+ action=_format_list_actions("examine","remove",prefix="Actions: "),
+ )
+
+ helptext="""
+ Here is an example of two lock strings:
+
+ edit:false()
+ call:tag(Foo) OR perm(Builder)
+
+ Above locks limit two things, 'edit' and 'call'. Which lock types are actually checked
+ depend on the typeclass of the object being spawned. Here 'edit' is never allowed by anyone
+ while 'call' is allowed to all accessors with a |ctag|n 'Foo' OR which has the
+ |cPermission|n 'Builder'.
+
+ |cAvailable lockfuncs:|n
+
+{lfuncs}
+ """.format(
+ lfuncs=_format_lockfuncs()
+ )
+
+ text=(text,helptext)
+
+ options=_wizard_options("locks","tags","permissions")
+ options.append({"key":"_default","goto":_locks_actions})
+
+ returntext,options
+
+
+# permissions node
+
+
+def_caller_permissions(caller):
+ prototype=_get_menu_prototype(caller)
+ perms=prototype.get("permissions",[])
+ returnperms
+
+
+def_display_perm(caller,permission,only_hierarchy=False):
+ hierarchy=settings.PERMISSION_HIERARCHY
+ perm_low=permission.lower()
+ txt=""
+ ifperm_lowin[prm.lower()forprminhierarchy]:
+ txt="Permission (in hieararchy): {}".format(
+ ", ".join(
+ [
+ "|w[{}]|n".format(prm)ifprm.lower()==perm_lowelse"|W{}|n".format(prm)
+ forprminhierarchy
+ ]
+ )
+ )
+ elifnotonly_hierarchy:
+ txt="Permission: '{}'".format(permission)
+ returntxt
+
+
+def_permission_select(caller,permission,**kwargs):
+ return(
+ "node_examine_entity",
+ {"text":_display_perm(caller,permission),"back":"permissions"},
+ )
+
+
+def_add_perm(caller,perm,**kwargs):
+ ifperm:
+ perm_low=perm.lower()
+ perms=_caller_permissions(caller)
+ perms_low=[prm.lower()forprminperms]
+ if"delete"inkwargs:
+ try:
+ ind=perms_low.index(perm_low)
+ delperms[ind]
+ text="Removed Permission '{}'.".format(perm)
+ exceptValueError:
+ text="Found no Permission to remove."
+ else:
+ ifperm_lowinperms_low:
+ text="Permission already set."
+ else:
+ perms.append(perm)
+ _set_prototype_value(caller,"permissions",perms)
+ text="Added Permission '{}'".format(perm)
+ returntext
+
+
+def_permissions_actions(caller,raw_inp,**kwargs):
+ """Parse actions for permission listing"""
+ choices=kwargs.get("available_choices",[])
+ perm,action=_default_parse(
+ raw_inp,choices,("examine","e"),("remove","r","delete","d")
+ )
+
+ ifperm:
+ ifaction=="examine":
+ return(
+ "node_examine_entity",
+ {"text":_display_perm(caller,perm),"back":"permissions"},
+ )
+ elifaction=="remove":
+ res=_add_perm(caller,perm,delete=True)
+ caller.msg(res)
+ else:
+ res=_add_perm(caller,raw_inp.strip())
+ caller.msg(res)
+ return"node_permissions"
+
+
+@list_node(_caller_permissions,_permission_select)
+defnode_permissions(caller):
+ def_currentcmp(pval,fval):
+ cmp1=[perm.lower()forperminpval]
+ return[permforperminfvalifperm.lower()notincmp1]
+
+ text="""
+ |cPermissions|n are simple strings used to grant access to this object. A permission is used
+ when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. Certain
+ permissions belong in the |cpermission hierarchy|n together with the |Wperm()|n lock
+ function.
+
+{current}
+ """.format(
+ current=_get_current_value(
+ caller,
+ "permissions",
+ comparer=_currentcmp,
+ formatter=lambdalst:"\n"+"\n".join(prmforprminlst),
+ only_inherit=True,
+ )
+ )
+ _set_actioninfo(caller,_format_list_actions("examine","remove",prefix="Actions: "))
+
+ helptext="""
+ Any string can act as a permission as long as a lock is set to look for it. Depending on the
+ lock, having a permission could even be negative (i.e. the lock is only passed if you
+ |wdon't|n have the 'permission'). The most common permissions are the hierarchical
+ permissions:
+
+{permissions}.
+
+ For example, a |clock|n string like "edit:perm(Builder)" will grant access to accessors
+ having the |cpermission|n "Builder" or higher.
+ """.format(
+ permissions=", ".join(settings.PERMISSION_HIERARCHY)
+ )
+
+ text=(text,helptext)
+
+ options=_wizard_options("permissions","locks","location")
+ options.append({"key":"_default","goto":_permissions_actions})
+
+ returntext,options
+
+
+# location node
+
+
+
[docs]defnode_location(caller):
+
+ text="""
+ The |cLocation|n of this object in the world. If not given, the object will spawn in the
+ inventory of |c{caller}|n by default.
+
+{current}
+ """.format(
+ caller=caller.key,current=_get_current_value(caller,"location")
+ )
+
+ helptext="""
+ You get the most control by not specifying the location - you can then teleport the spawned
+ objects as needed later. Setting the location may be useful for quickly populating a given
+ location. One could also consider randomizing the location using a $protfunc.
+
+ |c$protfuncs|n
+{pfuncs}
+ """.format(
+ pfuncs=_format_protfuncs()
+ )
+
+ text=(text,helptext)
+
+ options=_wizard_options("location","permissions","home",search=True)
+ options.append(
+ {
+ "key":"_default",
+ "goto":(_set_property,dict(prop="location",processor=lambdas:s.strip())),
+ }
+ )
+ returntext,options
+
+
+# home node
+
+
+
[docs]defnode_home(caller):
+
+ text="""
+ The |cHome|n location of an object is often only used as a backup - this is where the object
+ will be moved to if its location is deleted. The home location can also be used as an actual
+ home for characters to quickly move back to.
+
+ If unset, the global home default (|w{default}|n) will be used.
+
+{current}
+ """.format(
+ default=settings.DEFAULT_HOME,current=_get_current_value(caller,"home")
+ )
+ helptext="""
+ The home can be given as a #dbref but can also be specified using the protfunc
+ '$obj(name)'. Use |wSE|nearch to find objects in the database.
+
+ The home location is commonly not used except as a backup; using the global default is often
+ enough.
+
+ |c$protfuncs|n
+{pfuncs}
+ """.format(
+ pfuncs=_format_protfuncs()
+ )
+
+ text=(text,helptext)
+
+ options=_wizard_options("home","location","destination",search=True)
+ options.append(
+ {
+ "key":"_default",
+ "goto":(_set_property,dict(prop="home",processor=lambdas:s.strip())),
+ }
+ )
+ returntext,options
+
+
+# destination node
+
+
+
[docs]defnode_destination(caller):
+
+ text="""
+ The object's |cDestination|n is generally only used by Exit-like objects to designate where
+ the exit 'leads to'. It's usually unset for all other types of objects.
+
+{current}
+ """.format(
+ current=_get_current_value(caller,"destination")
+ )
+
+ helptext="""
+ The destination can be given as a #dbref but can also be specified using the protfunc
+ '$obj(name)'. Use |wSEearch to find objects in the database.
+
+ |c$protfuncs|n
+{pfuncs}
+ """.format(
+ pfuncs=_format_protfuncs()
+ )
+
+ text=(text,helptext)
+
+ options=_wizard_options("destination","home","prototype_desc",search=True)
+ options.append(
+ {
+ "key":"_default",
+ "goto":(_set_property,dict(prop="destination",processor=lambdas:s.strip())),
+ }
+ )
+ returntext,options
+
+
+# prototype_desc node
+
+
+
[docs]defnode_prototype_desc(caller):
+
+ text="""
+ The |cPrototype-Description|n briefly describes the prototype when it's viewed in listings.
+
+{current}
+ """.format(
+ current=_get_current_value(caller,"prototype_desc")
+ )
+
+ helptext="""
+ Giving a brief description helps you and others to locate the prototype for use later.
+ """
+
+ text=(text,helptext)
+
+ options=_wizard_options("prototype_desc","prototype_key","prototype_tags")
+ options.append(
+ {
+ "key":"_default",
+ "goto":(
+ _set_property,
+ dict(
+ prop="prototype_desc",
+ processor=lambdas:s.strip(),
+ next_node="node_prototype_desc",
+ ),
+ ),
+ }
+ )
+
+ returntext,options
+
+
+# prototype_tags node
+
+
+def_caller_prototype_tags(caller):
+ prototype=_get_menu_prototype(caller)
+ tags=prototype.get("prototype_tags",[])
+ tags=[tag[0]ifisinstance(tag,tuple)elsetagfortagintags]
+ returntags
+
+
+def_add_prototype_tag(caller,tag_string,**kwargs):
+ """
+ Add prototype_tags to the system. We only support straight tags, no
+ categories (category is assigned automatically).
+
+ Args:
+ caller (Object): Caller of menu.
+ tag_string (str): Input from user - only tagname
+
+ Keyword Args:
+ delete (str): If this is set, tag_string is considered
+ the name of the tag to delete.
+
+ Returns:
+ result (str): Result string of action.
+
+ """
+ tag=tag_string.strip().lower()
+
+ iftag:
+ tags=_caller_prototype_tags(caller)
+ exists=tagintags
+
+ if"delete"inkwargs:
+ ifexists:
+ tags.pop(tags.index(tag))
+ text="Removed Prototype-Tag '{}'.".format(tag)
+ else:
+ text="Found no Prototype-Tag to remove."
+ elifnotexists:
+ # a fresh, new tag
+ tags.append(tag)
+ text="Added Prototype-Tag '{}'.".format(tag)
+ else:
+ text="Prototype-Tag already added."
+
+ _set_prototype_value(caller,"prototype_tags",tags)
+ else:
+ text="No Prototype-Tag specified."
+
+ returntext
+
+
+def_prototype_tag_select(caller,tagname):
+ caller.msg("Prototype-Tag: {}".format(tagname))
+ return"node_prototype_tags"
+
+
+def_prototype_tags_actions(caller,raw_inp,**kwargs):
+ """Parse actions for tags listing"""
+ choices=kwargs.get("available_choices",[])
+ tagname,action=_default_parse(raw_inp,choices,("remove","r","delete","d"))
+
+ iftagname:
+ ifaction=="remove":
+ res=_add_prototype_tag(caller,tagname,delete=True)
+ caller.msg(res)
+ else:
+ res=_add_prototype_tag(caller,raw_inp.lower().strip())
+ caller.msg(res)
+ return"node_prototype_tags"
+
+
+@list_node(_caller_prototype_tags,_prototype_tag_select)
+defnode_prototype_tags(caller):
+
+ text="""
+ |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not
+ case-sensitive and can have not have a custom category.
+
+{current}
+ """.format(
+ current=_get_current_value(
+ caller,
+ "prototype_tags",
+ formatter=lambdalst:", ".join(tgfortginlst),
+ only_inherit=True,
+ )
+ )
+ _set_actioninfo(
+ caller,_format_list_actions("remove",prefix="|w<text>|n|W to add Tag. Other Action:|n ")
+ )
+ helptext="""
+ Using prototype-tags is a good way to organize and group large numbers of prototypes by
+ genre, type etc. Under the hood, prototypes' tags will all be stored with the category
+ '{tagmetacategory}'.
+ """.format(
+ tagmetacategory=protlib._PROTOTYPE_TAG_META_CATEGORY
+ )
+
+ text=(text,helptext)
+
+ options=_wizard_options("prototype_tags","prototype_desc","prototype_locks")
+ options.append({"key":"_default","goto":_prototype_tags_actions})
+
+ returntext,options
+
+
+# prototype_locks node
+
+
+def_caller_prototype_locks(caller):
+ locks=_get_menu_prototype(caller).get("prototype_locks","")
+ return[lckforlckinlocks.split(";")iflck]
+
+
+def_prototype_lock_select(caller,lockstr):
+ return(
+ "node_examine_entity",
+ {"text":_locks_display(caller,lockstr),"back":"prototype_locks"},
+ )
+
+
+def_prototype_lock_add(caller,lock,**kwargs):
+ locks=_caller_prototype_locks(caller)
+
+ try:
+ locktype,lockdef=lock.split(":",1)
+ exceptValueError:
+ return"Lockstring lacks ':'."
+
+ locktype=locktype.strip().lower()
+
+ if"delete"inkwargs:
+ try:
+ ind=locks.index(lock)
+ locks.pop(ind)
+ _set_prototype_value(caller,"prototype_locks",";".join(locks),parse=False)
+ ret="Prototype-lock {} deleted.".format(lock)
+ exceptValueError:
+ ret="No Prototype-lock found to delete."
+ returnret
+ try:
+ locktypes=[lck.split(":",1)[0].strip().lower()forlckinlocks]
+ ind=locktypes.index(locktype)
+ locks[ind]=lock
+ ret="Prototype-lock with locktype '{}' updated.".format(locktype)
+ exceptValueError:
+ locks.append(lock)
+ ret="Added Prototype-lock '{}'.".format(lock)
+ _set_prototype_value(caller,"prototype_locks",";".join(locks))
+ returnret
+
+
+def_prototype_locks_actions(caller,raw_inp,**kwargs):
+ choices=kwargs.get("available_choices",[])
+ lock,action=_default_parse(
+ raw_inp,choices,("examine","e"),("remove","r","delete","d")
+ )
+
+ iflock:
+ ifaction=="examine":
+ return("node_examine_entity",{"text":_locks_display(caller,lock),"back":"locks"})
+ elifaction=="remove":
+ ret=_prototype_lock_add(caller,lock.strip(),delete=True)
+ caller.msg(ret)
+ else:
+ ret=_prototype_lock_add(caller,raw_inp.strip())
+ caller.msg(ret)
+
+ return"node_prototype_locks"
+
+
+@list_node(_caller_prototype_locks,_prototype_lock_select)
+defnode_prototype_locks(caller):
+
+ text="""
+ |cPrototype-Locks|n are used to limit access to this prototype when someone else is trying
+ to access it. By default any prototype can be edited only by the creator and by Admins while
+ they can be used by anyone with access to the spawn command. There are two valid lock types
+ the prototype access tools look for:
+
+ - 'edit': Who can edit the prototype.
+ - 'spawn': Who can spawn new objects with this prototype.
+
+ If unsure, keep the open defaults.
+
+{current}
+ """.format(
+ current=_get_current_value(
+ caller,
+ "prototype_locks",
+ formatter=lambdalstring:"\n".join(
+ _locks_display(caller,lstr)forlstrinlstring.split(";")
+ ),
+ only_inherit=True,
+ )
+ )
+ _set_actioninfo(caller,_format_list_actions("examine","remove",prefix="Actions: "))
+
+ helptext="""
+ Prototype locks can be used to vary access for different tiers of builders. It also allows
+ developers to produce 'base prototypes' only meant for builders to inherit and expand on
+ rather than tweak in-place.
+ """
+
+ text=(text,helptext)
+
+ options=_wizard_options("prototype_locks","prototype_tags","index")
+ options.append({"key":"_default","goto":_prototype_locks_actions})
+
+ returntext,options
+
+
+# update existing objects node
+
+
+def_apply_diff(caller,**kwargs):
+ """update existing objects"""
+ prototype=kwargs["prototype"]
+ objects=kwargs["objects"]
+ back_node=kwargs["back_node"]
+ diff=kwargs.get("diff",None)
+ num_changed=spawner.batch_update_objects_with_prototype(prototype,diff=diff,objects=objects)
+ caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed))
+ returnback_node
+
+
+def_keep_diff(caller,**kwargs):
+ """Change to KEEP setting for a given section of a diff"""
+ # from evennia import set_trace;set_trace(term_size=(182, 50))
+ path=kwargs["path"]
+ diff=kwargs["diff"]
+ tmp=diff
+ forkeyinpath[:-1]:
+ tmp=tmp[key]
+ tmp[path[-1]]=tuple(list(tmp[path[-1]][:-1])+["KEEP"])
+
+
+def_format_diff_text_and_options(diff,minimal=True,**kwargs):
+ """
+ Reformat the diff in a way suitable for the olc menu.
+
+ Args:
+ diff (dict): A diff as produced by `prototype_diff`.
+ minimal (bool, optional): Don't show KEEPs.
+
+ Keyword Args:
+ any (any): Forwarded into the generated options as arguments to the callable.
+
+ Returns:
+ texts (list): List of texts.
+ options (list): List of options dict.
+
+ """
+ valid_instructions=("KEEP","REMOVE","ADD","UPDATE")
+
+ def_visualize(obj,rootname,get_name=False):
+ ifutils.is_iter(obj):
+ ifnotobj:
+ returnstr(obj)
+ ifget_name:
+ returnobj[0]ifobj[0]else"<unset>"
+ ifrootname=="attrs":
+ return"{} |W=|n {} |W(category:|n {}|W, locks:|n {}|W)|n".format(*obj)
+ elifrootname=="tags":
+ return"{} |W(category:|n {}|W)|n".format(obj[0],obj[1])
+
+ return"{}".format(obj)
+
+ def_parse_diffpart(diffpart,optnum,*args):
+ typ=type(diffpart)
+ texts=[]
+ options=[]
+ iftyp==tupleandlen(diffpart)==3anddiffpart[2]invalid_instructions:
+ rootname=args[0]
+ old,new,instruction=diffpart
+ ifinstruction=="KEEP":
+ ifnotminimal:
+ texts.append(" |gKEEP|W:|n {old}".format(old=_visualize(old,rootname)))
+ else:
+ # instructions we should be able to revert by a menu choice
+ vold=_visualize(old,rootname)
+ vnew=_visualize(new,rootname)
+ vsep=""iflen(vold)<78else"\n"
+
+ ifinstruction=="ADD":
+ texts.append(
+ " |c[{optnum}] |yADD|n: {new}".format(
+ optnum=optnum,new=_visualize(new,rootname)
+ )
+ )
+ elifinstruction=="REMOVE"andnotnew:
+ ifrootname=="tags"andold[1]==protlib.PROTOTYPE_TAG_CATEGORY:
+ # special exception for the prototype-tag mechanism
+ # this is added post-spawn automatically and should
+ # not be listed as REMOVE.
+ returntexts,options,optnum
+
+ texts.append(
+ " |c[{optnum}] |rREMOVE|n: {old}".format(
+ optnum=optnum,old=_visualize(old,rootname)
+ )
+ )
+ else:
+ vinst="|y{}|n".format(instruction)
+ texts.append(
+ " |c[{num}] {inst}|W:|n {old} |W->|n{sep}{new}".format(
+ inst=vinst,num=optnum,old=vold,sep=vsep,new=vnew
+ )
+ )
+ options.append(
+ {
+ "key":str(optnum),
+ "desc":"|gKEEP|n ({}) {}".format(
+ rootname,_visualize(old,args[-1],get_name=True)
+ ),
+ "goto":(_keep_diff,dict((("path",args),("diff",diff)),**kwargs)),
+ }
+ )
+ optnum+=1
+ else:
+ forkeyinsorted(list(diffpart.keys())):
+ subdiffpart=diffpart[key]
+ text,option,optnum=_parse_diffpart(subdiffpart,optnum,*(args+(key,)))
+ texts.extend(text)
+ options.extend(option)
+ returntexts,options,optnum
+
+ texts=[]
+ options=[]
+ # we use this to allow for skipping full KEEP instructions
+ optnum=1
+
+ forroot_keyinsorted(diff):
+ diffpart=diff[root_key]
+ text,option,optnum=_parse_diffpart(diffpart,optnum,root_key)
+ heading="- |w{}:|n ".format(root_key)
+ iftext:
+ text=[heading+text[0]]+text[1:]
+ else:
+ text=[heading]
+
+ texts.extend(text)
+ options.extend(option)
+
+ returntexts,options
+
+
+
[docs]defnode_apply_diff(caller,**kwargs):
+ """Offer options for updating objects"""
+
+ def_keep_option(keyname,prototype,base_obj,obj_prototype,diff,objects,back_node):
+ """helper returning an option dict"""
+ options={
+ "desc":"Keep {} as-is".format(keyname),
+ "goto":(
+ _keep_diff,
+ {
+ "key":keyname,
+ "prototype":prototype,
+ "base_obj":base_obj,
+ "obj_prototype":obj_prototype,
+ "diff":diff,
+ "objects":objects,
+ "back_node":back_node,
+ },
+ ),
+ }
+ returnoptions
+
+ prototype=kwargs.get("prototype",None)
+ update_objects=kwargs.get("objects",None)
+ back_node=kwargs.get("back_node","node_index")
+ obj_prototype=kwargs.get("obj_prototype",None)
+ base_obj=kwargs.get("base_obj",None)
+ diff=kwargs.get("diff",None)
+ custom_location=kwargs.get("custom_location",None)
+
+ ifnotupdate_objects:
+ text="There are no existing objects to update."
+ options={"key":"_default","goto":back_node}
+ returntext,options
+
+ ifnotdiff:
+ # use one random object as a reference to calculate a diff
+ base_obj=choice(update_objects)
+
+ diff,obj_prototype=spawner.prototype_diff_from_object(prototype,base_obj)
+
+ helptext="""
+ This will go through all existing objects and apply the changes you accept.
+
+ Be careful with this operation! The upgrade mechanism will try to automatically estimate
+ what changes need to be applied. But the estimate is |wonly based on the analysis of one
+ randomly selected object|n among all objects spawned by this prototype. If that object
+ happens to be unusual in some way the estimate will be off and may lead to unexpected
+ results for other objects. Always test your objects carefully after an upgrade and consider
+ being conservative (switch to KEEP) for things you are unsure of. For complex upgrades it
+ may be better to get help from an administrator with access to the `@py` command for doing
+ this manually.
+
+ Note that the `location` will never be auto-adjusted because it's so rare to want to
+ homogenize the location of all object instances."""
+
+ ifnotcustom_location:
+ diff.pop("location",None)
+
+ txt,options=_format_diff_text_and_options(
+ diff,objects=update_objects,base_obj=base_obj,prototype=prototype
+ )
+
+ ifoptions:
+ text=[
+ "Suggested changes to {} objects. ".format(len(update_objects)),
+ "Showing random example obj to change: {name} ({dbref}))\n".format(
+ name=base_obj.key,dbref=base_obj.dbref
+ ),
+ ]+txt
+ options.extend(
+ [
+ {
+ "key":("|wu|Wpdate {} objects".format(len(update_objects)),"update","u"),
+ "desc":"Update {} objects".format(len(update_objects)),
+ "goto":(
+ _apply_diff,
+ {
+ "prototype":prototype,
+ "objects":update_objects,
+ "back_node":back_node,
+ "diff":diff,
+ "base_obj":base_obj,
+ },
+ ),
+ },
+ {
+ "key":("|wr|Weset changes","reset","r"),
+ "goto":(
+ "node_apply_diff",
+ {"prototype":prototype,"back_node":back_node,"objects":update_objects},
+ ),
+ },
+ ]
+ )
+ else:
+ text=[
+ "Analyzed a random sample object (out of {}) - "
+ "found no changes to apply.".format(len(update_objects))
+ ]
+
+ options.extend(_wizard_options("update_objects",back_node[5:],None))
+ options.append({"key":"_default","goto":back_node})
+
+ text="\n".join(text)
+ text=(text,helptext)
+
+ returntext,options
+
+
+# prototype save node
+
+
+
[docs]defnode_prototype_save(caller,**kwargs):
+ """Save prototype to disk """
+ # these are only set if we selected 'yes' to save on a previous pass
+ prototype=kwargs.get("prototype",None)
+ # set to True/False if answered, None if first pass
+ accept_save=kwargs.get("accept_save",None)
+
+ ifaccept_saveandprototype:
+ # we already validated and accepted the save, so this node acts as a goto callback and
+ # should now only return the next node
+ prototype_key=prototype.get("prototype_key")
+ try:
+ protlib.save_prototype(prototype)
+ exceptExceptionasexc:
+ text="|rCould not save:|n {}\n(press Return to continue)".format(exc)
+ options={"key":"_default","goto":"node_index"}
+ returntext,options
+
+ spawned_objects=protlib.search_objects_with_prototype(prototype_key)
+ nspawned=spawned_objects.count()
+
+ text=["|gPrototype saved.|n"]
+
+ ifnspawned:
+ text.append(
+ "\nDo you want to update {} object(s) "
+ "already using this prototype?".format(nspawned)
+ )
+ options=(
+ {
+ "key":("|wY|Wes|n","yes","y"),
+ "desc":"Go to updating screen",
+ "goto":(
+ "node_apply_diff",
+ {
+ "accept_update":True,
+ "objects":spawned_objects,
+ "prototype":prototype,
+ "back_node":"node_prototype_save",
+ },
+ ),
+ },
+ {"key":("[|wN|Wo|n]","n"),"desc":"Return to index","goto":"node_index"},
+ {"key":"_default","goto":"node_index"},
+ )
+ else:
+ text.append("(press Return to continue)")
+ options={"key":"_default","goto":"node_index"}
+
+ text="\n".join(text)
+
+ helptext="""
+ Updating objects means that the spawner will find all objects previously created by this
+ prototype. You will be presented with a list of the changes the system will try to apply to
+ each of these objects and you can choose to customize that change if needed. If you have
+ done a lot of manual changes to your objects after spawning, you might want to update those
+ objects manually instead.
+ """
+
+ text=(text,helptext)
+
+ returntext,options
+
+ # not validated yet
+ prototype=_get_menu_prototype(caller)
+ error,text=_validate_prototype(prototype)
+
+ text=[text]
+
+ iferror:
+ # abort save
+ text.append(
+ "\n|yValidation errors were found. They need to be corrected before this prototype "
+ "can be saved (or used to spawn).|n"
+ )
+ options=_wizard_options("prototype_save","index",None)
+ options.append({"key":"_default","goto":"node_index"})
+ return"\n".join(text),options
+
+ prototype_key=prototype["prototype_key"]
+ ifprotlib.search_prototype(prototype_key):
+ text.append(
+ "\nDo you want to save/overwrite the existing prototype '{name}'?".format(
+ name=prototype_key
+ )
+ )
+ else:
+ text.append("\nDo you want to save the prototype as '{name}'?".format(name=prototype_key))
+
+ text="\n".join(text)
+
+ helptext="""
+ Saving the prototype makes it available for use later. It can also be used to inherit from,
+ by name. Depending on |cprototype-locks|n it also makes the prototype usable and/or
+ editable by others. Consider setting good |cPrototype-tags|n and to give a useful, brief
+ |cPrototype-desc|n to make the prototype easy to find later.
+
+ """
+
+ text=(text,helptext)
+
+ options=(
+ {
+ "key":("[|wY|Wes|n]","yes","y"),
+ "desc":"Save prototype",
+ "goto":("node_prototype_save",{"accept_save":True,"prototype":prototype}),
+ },
+ {"key":("|wN|Wo|n","n"),"desc":"Abort and return to Index","goto":"node_index"},
+ {
+ "key":"_default",
+ "goto":("node_prototype_save",{"accept_save":True,"prototype":prototype}),
+ },
+ )
+
+ returntext,options
+"""
+Protfuncs are function-strings embedded in a prototype and allows for a builder to create a
+prototype with custom logics without having access to Python. The Protfunc is parsed using the
+inlinefunc parser but is fired at the moment the spawning happens, using the creating object's
+session as input.
+
+In the prototype dict, the protfunc is specified as a string inside the prototype, e.g.:
+
+ { ...
+
+ "key": "$funcname(arg1, arg2, ...)"
+
+ ... }
+
+and multiple functions can be nested (no keyword args are supported). The result will be used as the
+value for that prototype key for that individual spawn.
+
+Available protfuncs are callables in one of the modules of `settings.PROT_FUNC_MODULES`. They
+are specified as functions
+
+ def funcname (*args, **kwargs)
+
+where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia:
+
+ - session (Session): The Session of the entity spawning using this prototype.
+ - prototype (dict): The dict this protfunc is a part of.
+ - current_key (str): The active key this value belongs to in the prototype.
+ - testing (bool): This is set if this function is called as part of the prototype validation; if
+ set, the protfunc should take care not to perform any persistent actions, such as operate on
+ objects or add things to the database.
+
+Any traceback raised by this function will be handled at the time of spawning and abort the spawn
+before any object is created/updated. It must otherwise return the value to store for the specified
+prototype key (this value must be possible to serialize in an Attribute).
+
+"""
+
+fromastimportliteral_eval
+fromrandomimportrandintasbase_randint,randomasbase_random,choiceasbase_choice
+importre
+
+fromevennia.utilsimportsearch
+fromevennia.utils.utilsimportjustifyasbase_justify,is_iter,to_str
+
+_PROTLIB=None
+
+_RE_DBREF=re.compile(r"\#[0-9]+")
+
+
+# default protfuncs
+
+
+
[docs]defrandom(*args,**kwargs):
+ """
+ Usage: $random()
+ Returns a random value in the interval [0, 1)
+
+ """
+ returnbase_random()
+
+
+
[docs]defrandint(*args,**kwargs):
+ """
+ Usage: $randint(start, end)
+ Returns random integer in interval [start, end]
+
+ """
+ iflen(args)!=2:
+ raiseTypeError("$randint needs two arguments - start and end.")
+ start,end=int(args[0]),int(args[1])
+ returnbase_randint(start,end)
[docs]defchoice(*args,**kwargs):
+ """
+ Usage: $choice(val, val, val, ...)
+ Returns one of the values randomly
+ """
+ ifargs:
+ returnbase_choice(args)
+ return""
+
+
+
[docs]deffull_justify(*args,**kwargs):
+
+ """
+ Usage: $full_justify(<text>)
+ Returns <text> filling up screen width by adding extra space.
+
+ """
+ ifargs:
+ returnbase_justify(args[0],align="f")
+ return""
+
+
+
[docs]defprotkey(*args,**kwargs):
+ """
+ Usage: $protkey(<key>)
+ Returns the value of another key in this prototoype. Will raise an error if
+ the key is not found in this prototype.
+
+ """
+ ifargs:
+ prototype=kwargs["prototype"]
+ returnprototype[args[0].strip()]
+
+
+
[docs]defadd(*args,**kwargs):
+ """
+ Usage: $add(val1, val2)
+ Returns the result of val1 + val2. Values must be
+ valid simple Python structures possible to add,
+ such as numbers, lists etc.
+
+ """
+ iflen(args)>1:
+ val1,val2=args[0],args[1]
+ # try to convert to python structures, otherwise, keep as strings
+ try:
+ val1=literal_eval(val1.strip())
+ exceptException:
+ pass
+ try:
+ val2=literal_eval(val2.strip())
+ exceptException:
+ pass
+ returnval1+val2
+ raiseValueError("$add requires two arguments.")
+
+
+
[docs]defsub(*args,**kwargs):
+ """
+ Usage: $del(val1, val2)
+ Returns the value of val1 - val2. Values must be
+ valid simple Python structures possible to
+ subtract.
+
+ """
+ iflen(args)>1:
+ val1,val2=args[0],args[1]
+ # try to convert to python structures, otherwise, keep as strings
+ try:
+ val1=literal_eval(val1.strip())
+ exceptException:
+ pass
+ try:
+ val2=literal_eval(val2.strip())
+ exceptException:
+ pass
+ returnval1-val2
+ raiseValueError("$sub requires two arguments.")
+
+
+
[docs]defmult(*args,**kwargs):
+ """
+ Usage: $mul(val1, val2)
+ Returns the value of val1 * val2. The values must be
+ valid simple Python structures possible to
+ multiply, like strings and/or numbers.
+
+ """
+ iflen(args)>1:
+ val1,val2=args[0],args[1]
+ # try to convert to python structures, otherwise, keep as strings
+ try:
+ val1=literal_eval(val1.strip())
+ exceptException:
+ pass
+ try:
+ val2=literal_eval(val2.strip())
+ exceptException:
+ pass
+ returnval1*val2
+ raiseValueError("$mul requires two arguments.")
+
+
+
[docs]defdiv(*args,**kwargs):
+ """
+ Usage: $div(val1, val2)
+ Returns the value of val1 / val2. Values must be numbers and
+ the result is always a float.
+
+ """
+ iflen(args)>1:
+ val1,val2=args[0],args[1]
+ # try to convert to python structures, otherwise, keep as strings
+ try:
+ val1=literal_eval(val1.strip())
+ exceptException:
+ pass
+ try:
+ val2=literal_eval(val2.strip())
+ exceptException:
+ pass
+ returnval1/float(val2)
+ raiseValueError("$mult requires two arguments.")
[docs]defeval(*args,**kwargs):
+ """
+ Usage $eval(<expression>)
+ Returns evaluation of a simple Python expression. The string may *only* consist of the following
+ Python literal structures: strings, numbers, tuples, lists, dicts, booleans,
+ and None. The strings can also contain #dbrefs. Escape embedded protfuncs as $$protfunc(..)
+ - those will then be evaluated *after* $eval.
+
+ """
+ global_PROTLIB
+ ifnot_PROTLIB:
+ fromevennia.prototypesimportprototypesas_PROTLIB
+
+ string=",".join(args)
+ struct=literal_eval(string)
+
+ ifisinstance(struct,str):
+ # we must shield the string, otherwise it will be merged as a string and future
+ # literal_evas will pick up e.g. '2' as something that should be converted to a number
+ struct='"{}"'.format(struct)
+
+ # convert any #dbrefs to objects (also in nested structures)
+ struct=_PROTLIB.value_to_obj_or_any(struct)
+
+ returnstruct
+
+
+def_obj_search(*args,**kwargs):
+ "Helper function to search for an object"
+
+ query="".join(args)
+ session=kwargs.get("session",None)
+ return_list=kwargs.pop("return_list",False)
+ account=None
+
+ ifsession:
+ account=session.account
+
+ targets=search.search_object(query)
+
+ ifreturn_list:
+ retlist=[]
+ ifaccount:
+ fortargetintargets:
+ iftarget.access(account,target,"control"):
+ retlist.append(target)
+ else:
+ retlist=targets
+ returnretlist
+ else:
+ # single-match
+ ifnottargets:
+ raiseValueError("$obj: Query '{}' gave no matches.".format(query))
+ iflen(targets)>1:
+ raiseValueError(
+ "$obj: Query '{query}' gave {nmatches} matches. Limit your "
+ "query or use $objlist instead.".format(query=query,nmatches=len(targets))
+ )
+ target=targets[0]
+ ifaccount:
+ ifnottarget.access(account,target,"control"):
+ raiseValueError(
+ "$obj: Obj {target}(#{dbref} cannot be added - "
+ "Account {account} does not have 'control' access.".format(
+ target=target.key,dbref=target.id,account=account
+ )
+ )
+ returntarget
+
+
+
[docs]defobj(*args,**kwargs):
+ """
+ Usage $obj(<query>)
+ Returns one Object searched globally by key, alias or #dbref. Error if more than one.
+
+ """
+ obj=_obj_search(return_list=False,*args,**kwargs)
+ ifobj:
+ return"#{}".format(obj.id)
+ return"".join(args)
+
+
+
[docs]defobjlist(*args,**kwargs):
+ """
+ Usage $objlist(<query>)
+ Returns list with one or more Objects searched globally by key, alias or #dbref.
+
+ """
+ return["#{}".format(obj.id)forobjin_obj_search(return_list=True,*args,**kwargs)]
+
+
+
[docs]defdbref(*args,**kwargs):
+ """
+ Usage $dbref(<#dbref>)
+ Validate that a #dbref input is valid.
+ """
+ ifnotargsorlen(args)<1or_RE_DBREF.match(args[0])isNone:
+ raiseValueError("$dbref requires a valid #dbref argument.")
+
+ returnobj(args[0])
[docs]defhomogenize_prototype(prototype,custom_keys=None):
+ """
+ Homogenize the more free-form prototype supported pre Evennia 0.7 into the stricter form.
+
+
+ Args:
+ prototype (dict): Prototype.
+ custom_keys (list, optional): Custom keys which should not be interpreted as attrs, beyond
+ the default reserved keys.
+
+ Returns:
+ homogenized (dict): Prototype where all non-identified keys grouped as attributes and other
+ homogenizations like adding missing prototype_keys and setting a default typeclass.
+
+ """
+ ifnotprototypeornotisinstance(prototype,dict):
+ return{}
+
+ reserved=_PROTOTYPE_RESERVED_KEYS+(custom_keysor())
+
+ # correct cases of setting None for certain values
+ forprotkeyinprototype:
+ ifprototype[protkey]isNone:
+ ifprotkeyin("attrs","tags","prototype_tags"):
+ prototype[protkey]=[]
+ elifprotkeyin("prototype_key","prototype_desc"):
+ prototype[protkey]=""
+
+ attrs=list(prototype.get("attrs",[]))# break reference
+ tags=make_iter(prototype.get("tags",[]))
+ homogenized_tags=[]
+
+ homogenized={}
+ forkey,valinprototype.items():
+ ifkeyinreserved:
+ ifkey=="tags":
+ fortagintags:
+ ifnotis_iter(tag):
+ homogenized_tags.append((tag,None,None))
+ else:
+ homogenized_tags.append(tag)
+ else:
+ homogenized[key]=val
+ else:
+ # unassigned keys -> attrs
+ attrs.append((key,val,None,""))
+ ifattrs:
+ homogenized["attrs"]=attrs
+ ifhomogenized_tags:
+ homogenized["tags"]=homogenized_tags
+
+ # add required missing parts that had defaults before
+
+ homogenized["prototype_key"]=homogenized.get(
+ "prototype_key",
+ # assign a random hash as key
+ "prototype-{}".format(hashlib.md5(bytes(str(time.time()),"utf-8")).hexdigest()[:7]),
+ )
+ homogenized["prototype_tags"]=homogenized.get("prototype_tags",[])
+ homogenized["prototype_locks"]=homogenized.get("prototype_lock",_PROTOTYPE_FALLBACK_LOCK)
+ homogenized["prototype_desc"]=homogenized.get("prototype_desc","")
+ if"typeclass"notinprototypeand"prototype_parent"notinprototype:
+ homogenized["typeclass"]=settings.BASE_OBJECT_TYPECLASS
+
+ returnhomogenized
+
+
+# module-based prototypes
+
+
[docs]defload_module_prototypes():
+ """
+ This is called by `evennia.__init__` as Evennia initializes. It's important
+ to do this late so as to not interfere with evennia initialization.
+
+ """
+ formodinsettings.PROTOTYPE_MODULES:
+ # to remove a default prototype, override it with an empty dict.
+ # internally we store as (key, desc, locks, tags, prototype_dict)
+ prots=[]
+ forvariable_name,protinall_from_module(mod).items():
+ ifisinstance(prot,dict):
+ if"prototype_key"notinprot:
+ prot["prototype_key"]=variable_name.lower()
+ prots.append((prot["prototype_key"],homogenize_prototype(prot)))
+ # assign module path to each prototype_key for easy reference
+ _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower():modforprototype_key,_inprots})
+ # make sure the prototype contains all meta info
+ forprototype_key,protinprots:
+ actual_prot_key=prot.get("prototype_key",prototype_key).lower()
+ prot.update(
+ {
+ "prototype_key":actual_prot_key,
+ "prototype_desc":prot["prototype_desc"]if"prototype_desc"inprotelsemod,
+ "prototype_locks":(
+ prot["prototype_locks"]
+ if"prototype_locks"inprot
+ else"use:all();edit:false()"
+ ),
+ "prototype_tags":list(
+ set(list(make_iter(prot.get("prototype_tags",[])))+["module"])
+ ),
+ }
+ )
+ _MODULE_PROTOTYPES[actual_prot_key]=prot
+
+
+# Db-based prototypes
+
+
+
[docs]classDbPrototype(DefaultScript):
+ """
+ This stores a single prototype, in an Attribute `prototype`.
+ """
+
+
[docs]defat_script_creation(self):
+ self.key="empty prototype"# prototype_key
+ self.desc="A prototype"# prototype_desc (.tags are used for prototype_tags)
+ self.db.prototype={}# actual prototype
[docs]defsave_prototype(prototype):
+ """
+ Create/Store a prototype persistently.
+
+ Args:
+ prototype (dict): The prototype to save. A `prototype_key` key is
+ required.
+
+ Returns:
+ prototype (dict or None): The prototype stored using the given kwargs, None if deleting.
+
+ Raises:
+ prototypes.ValidationError: If prototype does not validate.
+
+ Note:
+ No edit/spawn locks will be checked here - if this function is called the caller
+ is expected to have valid permissions.
+
+ """
+ in_prototype=prototype
+ in_prototype=homogenize_prototype(in_prototype)
+
+ def_to_batchtuple(inp,*args):
+ "build tuple suitable for batch-creation"
+ ifis_iter(inp):
+ # already a tuple/list, use as-is
+ returninp
+ return(inp,)+args
+
+ prototype_key=in_prototype.get("prototype_key")
+ ifnotprototype_key:
+ raiseValidationError("Prototype requires a prototype_key")
+
+ prototype_key=str(prototype_key).lower()
+
+ # we can't edit a prototype defined in a module
+ ifprototype_keyin_MODULE_PROTOTYPES:
+ mod=_MODULE_PROTOTYPE_MODULES.get(prototype_key,"N/A")
+ raisePermissionError(
+ "{} is a read-only prototype ""(defined as code in {}).".format(prototype_key,mod)
+ )
+
+ # make sure meta properties are included with defaults
+ in_prototype["prototype_desc"]=in_prototype.get(
+ "prototype_desc",prototype.get("prototype_desc","")
+ )
+ prototype_locks=in_prototype.get(
+ "prototype_locks",prototype.get("prototype_locks",_PROTOTYPE_FALLBACK_LOCK)
+ )
+ is_valid,err=validate_lockstring(prototype_locks)
+ ifnotis_valid:
+ raiseValidationError("Lock error: {}".format(err))
+ in_prototype["prototype_locks"]=prototype_locks
+
+ prototype_tags=[
+ _to_batchtuple(tag,_PROTOTYPE_TAG_META_CATEGORY)
+ fortaginmake_iter(
+ in_prototype.get("prototype_tags",prototype.get("prototype_tags",[]))
+ )
+ ]
+ in_prototype["prototype_tags"]=prototype_tags
+
+ stored_prototype=DbPrototype.objects.filter(db_key=prototype_key)
+ ifstored_prototype:
+ # edit existing prototype
+ stored_prototype=stored_prototype[0]
+ stored_prototype.desc=in_prototype["prototype_desc"]
+ ifprototype_tags:
+ stored_prototype.tags.clear(category=PROTOTYPE_TAG_CATEGORY)
+ stored_prototype.tags.batch_add(*in_prototype["prototype_tags"])
+ stored_prototype.locks.add(in_prototype["prototype_locks"])
+ stored_prototype.attributes.add("prototype",in_prototype)
+ else:
+ # create a new prototype
+ stored_prototype=create_script(
+ DbPrototype,
+ key=prototype_key,
+ desc=in_prototype["prototype_desc"],
+ persistent=True,
+ locks=prototype_locks,
+ tags=in_prototype["prototype_tags"],
+ attributes=[("prototype",in_prototype)],
+ )
+ returnstored_prototype.prototype
+
+
+create_prototype=save_prototype# alias
+
+
+
[docs]defdelete_prototype(prototype_key,caller=None):
+ """
+ Delete a stored prototype
+
+ Args:
+ key (str): The persistent prototype to delete.
+ caller (Account or Object, optionsl): Caller aiming to delete a prototype.
+ Note that no locks will be checked if`caller` is not passed.
+ Returns:
+ success (bool): If deletion worked or not.
+ Raises:
+ PermissionError: If 'edit' lock was not passed or deletion failed for some other reason.
+
+ """
+ ifprototype_keyin_MODULE_PROTOTYPES:
+ mod=_MODULE_PROTOTYPE_MODULES.get(prototype_key.lower(),"N/A")
+ raisePermissionError(
+ "{} is a read-only prototype ""(defined as code in {}).".format(prototype_key,mod)
+ )
+
+ stored_prototype=DbPrototype.objects.filter(db_key__iexact=prototype_key)
+
+ ifnotstored_prototype:
+ raisePermissionError("Prototype {} was not found.".format(prototype_key))
+
+ stored_prototype=stored_prototype[0]
+ ifcaller:
+ ifnotstored_prototype.access(caller,"edit"):
+ raisePermissionError(
+ "{} needs explicit 'edit' permissions to "
+ "delete prototype {}.".format(caller,prototype_key)
+ )
+ stored_prototype.delete()
+ returnTrue
+
+
+
[docs]defsearch_prototype(key=None,tags=None,require_single=False,return_iterators=False):
+ """
+ Find prototypes based on key and/or tags, or all prototypes.
+
+ Keyword Args:
+ key (str): An exact or partial key to query for.
+ tags (str or list): Tag key or keys to query for. These
+ will always be applied with the 'db_protototype'
+ tag category.
+ require_single (bool): If set, raise KeyError if the result
+ was not found or if there are multiple matches.
+ return_iterators (bool): Optimized return for large numbers of db-prototypes.
+ If set, separate returns of module based prototypes and paginate
+ the db-prototype return.
+
+ Return:
+ matches (list): Default return, all found prototype dicts. Empty list if
+ no match was found. Note that if neither `key` nor `tags`
+ were given, *all* available prototypes will be returned.
+ list, queryset: If `return_iterators` are found, this is a list of
+ module-based prototypes followed by a *paginated* queryset of
+ db-prototypes.
+
+ Raises:
+ KeyError: If `require_single` is True and there are 0 or >1 matches.
+
+ Note:
+ The available prototypes is a combination of those supplied in
+ PROTOTYPE_MODULES and those stored in the database. Note that if
+ tags are given and the prototype has no tags defined, it will not
+ be found as a match.
+
+ """
+ # prototype keys are always in lowecase
+ ifkey:
+ key=key.lower()
+
+ # search module prototypes
+
+ mod_matches={}
+ iftags:
+ # use tags to limit selection
+ tagset=set(tags)
+ mod_matches={
+ prototype_key:prototype
+ forprototype_key,prototypein_MODULE_PROTOTYPES.items()
+ iftagset.intersection(prototype.get("prototype_tags",[]))
+ }
+ else:
+ mod_matches=_MODULE_PROTOTYPES
+
+ allow_fuzzy=True
+ ifkey:
+ ifkeyinmod_matches:
+ # exact match
+ module_prototypes=[mod_matches[key]]
+ allow_fuzzy=False
+ else:
+ # fuzzy matching
+ module_prototypes=[
+ prototype
+ forprototype_key,prototypeinmod_matches.items()
+ ifkeyinprototype_key
+ ]
+ else:
+ module_prototypes=[matchformatchinmod_matches.values()]
+
+ # search db-stored prototypes
+
+ iftags:
+ # exact match on tag(s)
+ tags=make_iter(tags)
+ tag_categories=["db_prototype"for_intags]
+ db_matches=DbPrototype.objects.get_by_tag(tags,tag_categories)
+ else:
+ db_matches=DbPrototype.objects.all()
+
+ ifkey:
+ # exact or partial match on key
+ exact_match=db_matches.filter(Q(db_key__iexact=key)).order_by("db_key")
+ ifnotexact_matchandallow_fuzzy:
+ # try with partial match instead
+ db_matches=db_matches.filter(Q(db_key__icontains=key)).order_by("db_key")
+ else:
+ db_matches=exact_match
+
+ # convert to prototype
+ db_ids=db_matches.values_list("id",flat=True)
+ db_matches=(
+ Attribute.objects.filter(scriptdb__pk__in=db_ids,db_key="prototype")
+ .values_list("db_value",flat=True)
+ .order_by("scriptdb__db_key")
+ )
+ ifkeyandrequire_single:
+ nmodules=len(module_prototypes)
+ ndbprots=db_matches.count()
+ ifnmodules+ndbprots!=1:
+ raiseKeyError(f"Found {nmodules+ndbprots} matching prototypes {module_prototypes}.")
+
+ ifreturn_iterators:
+ # trying to get the entire set of prototypes - we must paginate
+ # the result instead of trying to fetch the entire set at once
+ returndb_matches,module_prototypes
+ else:
+ # full fetch, no pagination (compatibility mode)
+ returnlist(db_matches)+module_prototypes
+
+
+
[docs]defsearch_objects_with_prototype(prototype_key):
+ """
+ Retrieve all object instances created by a given prototype.
+
+ Args:
+ prototype_key (str): The exact (and unique) prototype identifier to query for.
+
+ Returns:
+ matches (Queryset): All matching objects spawned from this prototype.
+
+ """
+ returnObjectDB.objects.get_by_tag(key=prototype_key,category=PROTOTYPE_TAG_CATEGORY)
+
+
+
[docs]classPrototypeEvMore(EvMore):
+ """
+ Listing 1000+ prototypes can be very slow. So we customize EvMore to
+ display an EvTable per paginated page rather than to try creating an
+ EvTable for the entire dataset and then paginate it.
+ """
+
+
[docs]def__init__(self,caller,*args,session=None,**kwargs):
+ """Store some extra properties on the EvMore class"""
+ self.show_non_use=kwargs.pop("show_non_use",False)
+ self.show_non_edit=kwargs.pop("show_non_edit",False)
+ super().__init__(caller,*args,session=session,**kwargs)
+
+
[docs]definit_pages(self,inp):
+ """
+ This will be initialized with a tuple (mod_prototype_list, paginated_db_query)
+ and we must handle these separately since they cannot be paginated in the same
+ way. We will build the prototypes so that the db-prototypes come first (they
+ are likely the most volatile), followed by the mod-prototypes.
+ """
+ dbprot_query,modprot_list=inp
+ # set the number of entries per page to half the reported height of the screen
+ # to account for long descs etc
+ dbprot_paged=Paginator(dbprot_query,max(1,int(self.height/2)))
+
+ # we separate the different types of data, so we track how many pages there are
+ # of each.
+ n_mod=len(modprot_list)
+ self._npages_mod=n_mod//self.height+(0ifn_mod%self.height==0else1)
+ self._db_count=dbprot_paged.count
+ self._npages_db=dbprot_paged.num_pagesifself._db_count>0else0
+ # total number of pages
+ self._npages=self._npages_mod+self._npages_db
+ self._data=(dbprot_paged,modprot_list)
+ self._paginator=self.prototype_paginator
+
+
[docs]defprototype_paginator(self,pageno):
+ """
+ The listing is separated in db/mod prototypes, so we need to figure out which
+ one to pick based on the page number. Also, pageno starts from 0.
+ """
+ dbprot_pages,modprot_list=self._data
+
+ ifself._db_countandpageno<self._npages_db:
+ returndbprot_pages.page(pageno+1)
+ else:
+ # get the correct slice, adjusted for the db-prototypes
+ pageno=max(0,pageno-self._npages_db)
+ returnmodprot_list[pageno*self.height:pageno*self.height+self.height]
[docs]deflist_prototypes(
+ caller,key=None,tags=None,show_non_use=False,show_non_edit=True,session=None
+):
+ """
+ Collate a list of found prototypes based on search criteria and access.
+
+ Args:
+ caller (Account or Object): The object requesting the list.
+ key (str, optional): Exact or partial prototype key to query for.
+ tags (str or list, optional): Tag key or keys to query for.
+ show_non_use (bool, optional): Show also prototypes the caller may not use.
+ show_non_edit (bool, optional): Show also prototypes the caller may not edit.
+ session (Session, optional): If given, this is used for display formatting.
+ Returns:
+ PrototypeEvMore: An EvMore subclass optimized for prototype listings.
+ None: If no matches were found. In this case the caller has already been notified.
+
+ """
+ # this allows us to pass lists of empty strings
+ tags=[tagfortaginmake_iter(tags)iftag]
+
+ dbprot_query,modprot_list=search_prototype(key,tags,return_iterators=True)
+
+ ifnotdbprot_queryandnotmodprot_list:
+ caller.msg("No prototypes found.",session=session)
+ returnNone
+
+ # get specific prototype (one value or exception)
+ returnPrototypeEvMore(
+ caller,
+ (dbprot_query,modprot_list),
+ session=session,
+ show_non_use=show_non_use,
+ show_non_edit=show_non_edit,
+ )
+
+
+
[docs]defvalidate_prototype(
+ prototype,protkey=None,protparents=None,is_prototype_base=True,strict=True,_flags=None
+):
+ """
+ Run validation on a prototype, checking for inifinite regress.
+
+ Args:
+ prototype (dict): Prototype to validate.
+ protkey (str, optional): The name of the prototype definition. If not given, the prototype
+ dict needs to have the `prototype_key` field set.
+ protpartents (dict, optional): The available prototype parent library. If
+ note given this will be determined from settings/database.
+ is_prototype_base (bool, optional): We are trying to create a new object *based on this
+ object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent
+ etc.
+ strict (bool, optional): If unset, don't require needed keys, only check against infinite
+ recursion etc.
+ _flags (dict, optional): Internal work dict that should not be set externally.
+ Raises:
+ RuntimeError: If prototype has invalid structure.
+ RuntimeWarning: If prototype has issues that would make it unsuitable to build an object
+ with (it may still be useful as a mix-in prototype).
+
+ """
+ assertisinstance(prototype,dict)
+
+ if_flagsisNone:
+ _flags={"visited":[],"depth":0,"typeclass":False,"errors":[],"warnings":[]}
+
+ ifnotprotparents:
+ protparents={
+ prototype.get("prototype_key","").lower():prototype
+ forprototypeinsearch_prototype()
+ }
+
+ protkey=protkeyandprotkey.lower()orprototype.get("prototype_key",None)
+
+ ifstrictandnotbool(protkey):
+ _flags["errors"].append("Prototype lacks a `prototype_key`.")
+ protkey="[UNSET]"
+
+ typeclass=prototype.get("typeclass")
+ prototype_parent=prototype.get("prototype_parent",[])
+
+ ifstrictandnot(typeclassorprototype_parent):
+ ifis_prototype_base:
+ _flags["errors"].append(
+ "Prototype {} requires `typeclass` ""or 'prototype_parent'.".format(protkey)
+ )
+ else:
+ _flags["warnings"].append(
+ "Prototype {} can only be used as a mixin since it lacks "
+ "a typeclass or a prototype_parent.".format(protkey)
+ )
+
+ ifstrictandtypeclass:
+ try:
+ class_from_module(typeclass)
+ exceptImportErroraserr:
+ _flags["errors"].append(
+ "{}: Prototype {} is based on typeclass {}, which could not be imported!".format(
+ err,protkey,typeclass
+ )
+ )
+
+ # recursively traverse prototype_parent chain
+
+ forprotstringinmake_iter(prototype_parent):
+ protstring=protstring.lower()
+ ifprotkeyisnotNoneandprotstring==protkey:
+ _flags["errors"].append("Prototype {} tries to parent itself.".format(protkey))
+ protparent=protparents.get(protstring)
+ ifnotprotparent:
+ _flags["errors"].append(
+ "Prototype {}'s prototype_parent '{}' was not found.".format(protkey,protstring)
+ )
+ ifid(prototype)in_flags["visited"]:
+ _flags["errors"].append(
+ "{} has infinite nesting of prototypes.".format(protkeyorprototype)
+ )
+
+ if_flags["errors"]:
+ raiseRuntimeError("Error: "+"\nError: ".join(_flags["errors"]))
+ _flags["visited"].append(id(prototype))
+ _flags["depth"]+=1
+ validate_prototype(
+ protparent,protstring,protparents,is_prototype_base=is_prototype_base,_flags=_flags
+ )
+ _flags["visited"].pop()
+ _flags["depth"]-=1
+
+ iftypeclassandnot_flags["typeclass"]:
+ _flags["typeclass"]=typeclass
+
+ # if we get back to the current level without a typeclass it's an error.
+ ifstrictandis_prototype_baseand_flags["depth"]<=0andnot_flags["typeclass"]:
+ _flags["errors"].append(
+ "Prototype {} has no `typeclass` defined anywhere in its parent\n "
+ "chain. Add `typeclass`, or a `prototype_parent` pointing to a "
+ "prototype with a typeclass.".format(protkey)
+ )
+
+ if_flags["depth"]<=0:
+ if_flags["errors"]:
+ raiseRuntimeError("Error: "+"\nError: ".join(_flags["errors"]))
+ if_flags["warnings"]:
+ raiseRuntimeWarning("Warning: "+"\nWarning: ".join(_flags["warnings"]))
+
+ # make sure prototype_locks are set to defaults
+ prototype_locks=[
+ lstring.split(":",1)
+ forlstringinprototype.get("prototype_locks","").split(";")
+ if":"inlstring
+ ]
+ locktypes=[tup[0].strip()fortupinprototype_locks]
+ if"spawn"notinlocktypes:
+ prototype_locks.append(("spawn","all()"))
+ if"edit"notinlocktypes:
+ prototype_locks.append(("edit","all()"))
+ prototype_locks=";".join(":".join(tup)fortupinprototype_locks)
+ prototype["prototype_locks"]=prototype_locks
[docs]defprotfunc_parser(value,available_functions=None,testing=False,stacktrace=False,**kwargs):
+ """
+ Parse a prototype value string for a protfunc and process it.
+
+ Available protfuncs are specified as callables in one of the modules of
+ `settings.PROTFUNC_MODULES`, or specified on the command line.
+
+ Args:
+ value (any): The value to test for a parseable protfunc. Only strings will be parsed for
+ protfuncs, all other types are returned as-is.
+ available_functions (dict, optional): Mapping of name:protfunction to use for this parsing.
+ If not set, use default sources.
+ testing (bool, optional): Passed to protfunc. If in a testing mode, some protfuncs may
+ behave differently.
+ stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser.
+
+ Keyword Args:
+ session (Session): Passed to protfunc. Session of the entity spawning the prototype.
+ protototype (dict): Passed to protfunc. The dict this protfunc is a part of.
+ current_key(str): Passed to protfunc. The key in the prototype that will hold this value.
+ any (any): Passed on to the protfunc.
+
+ Returns:
+ testresult (tuple): If `testing` is set, returns a tuple (error, result) where error is
+ either None or a string detailing the error from protfunc_parser or seen when trying to
+ run `literal_eval` on the parsed string.
+ any (any): A structure to replace the string on the prototype level. If this is a
+ callable or a (callable, (args,)) structure, it will be executed as if one had supplied
+ it to the prototype directly. This structure is also passed through literal_eval so one
+ can get actual Python primitives out of it (not just strings). It will also identify
+ eventual object #dbrefs in the output from the protfunc.
+
+ """
+ ifnotisinstance(value,str):
+ returnvalue
+
+ available_functions=PROT_FUNCSifavailable_functionsisNoneelseavailable_functions
+
+ result=inlinefuncs.parse_inlinefunc(
+ value,available_funcs=available_functions,stacktrace=stacktrace,testing=testing,**kwargs
+ )
+
+ err=None
+ try:
+ result=literal_eval(result)
+ exceptValueError:
+ pass
+ exceptExceptionasexc:
+ err=str(exc)
+ iftesting:
+ returnerr,result
+ returnresult
+
+
+# Various prototype utilities
+
+
+
[docs]defformat_available_protfuncs():
+ """
+ Get all protfuncs in a pretty-formatted form.
+
+ Args:
+ clr (str, optional): What coloration tag to use.
+ """
+ out=[]
+ forprotfunc_name,protfuncinPROT_FUNCS.items():
+ out.append(
+ "- |c${name}|n - |W{docs}".format(
+ name=protfunc_name,docs=protfunc.__doc__.strip().replace("\n","")
+ )
+ )
+ returnjustify("\n".join(out),indent=8)
[docs]defcheck_permission(prototype_key,action,default=True):
+ """
+ Helper function to check access to actions on given prototype.
+
+ Args:
+ prototype_key (str): The prototype to affect.
+ action (str): One of "spawn" or "edit".
+ default (str): If action is unknown or prototype has no locks
+
+ Returns:
+ passes (bool): If permission for action is granted or not.
+
+ """
+ ifaction=="edit":
+ ifprototype_keyin_MODULE_PROTOTYPES:
+ mod=_MODULE_PROTOTYPE_MODULES.get(prototype_key,"N/A")
+ logger.log_err(
+ "{} is a read-only prototype ""(defined as code in {}).".format(prototype_key,mod)
+ )
+ returnFalse
+
+ prototype=search_prototype(key=prototype_key)
+ ifnotprototype:
+ logger.log_err("Prototype {} not found.".format(prototype_key))
+ returnFalse
+
+ lockstring=prototype.get("prototype_locks")
+
+ iflockstring:
+ returncheck_lockstring(None,lockstring,default=default,access_type=action)
+ returndefault
+
+
+
[docs]definit_spawn_value(value,validator=None):
+ """
+ Analyze the prototype value and produce a value useful at the point of spawning.
+
+ Args:
+ value (any): This can be:
+ callable - will be called as callable()
+ (callable, (args,)) - will be called as callable(*args)
+ other - will be assigned depending on the variable type
+ validator (callable, optional): If given, this will be called with the value to
+ check and guarantee the outcome is of a given type.
+
+ Returns:
+ any (any): The (potentially pre-processed value to use for this prototype key)
+
+ """
+ validator=validatorifvalidatorelselambdao:o
+ ifcallable(value):
+ value=validator(value())
+ elifvalueandisinstance(value,(list,tuple))andcallable(value[0]):
+ # a structure (callable, (args, ))
+ args=value[1:]
+ value=validator(value[0](*make_iter(args)))
+ else:
+ value=validator(value)
+ result=protfunc_parser(value)
+ ifresult!=value:
+ returnvalidator(result)
+ returnresult
+"""
+Spawner
+
+The spawner takes input files containing object definitions in
+dictionary forms. These use a prototype architecture to define
+unique objects without having to make a Typeclass for each.
+
+There main function is `spawn(*prototype)`, where the `prototype`
+is a dictionary like this:
+
+```python
+from evennia.prototypes import prototypes, spawner
+
+prot = {
+ "prototype_key": "goblin",
+ "typeclass": "types.objects.Monster",
+ "key": "goblin grunt",
+ "health": lambda: randint(20,30),
+ "resists": ["cold", "poison"],
+ "attacks": ["fists"],
+ "weaknesses": ["fire", "light"]
+ "tags": ["mob", "evil", ('greenskin','mob')]
+ "attrs": [("weapon", "sword")]
+}
+# spawn something with the prototype
+goblin = spawner.spawn(prot)
+
+# make this into a db-saved prototype (optional)
+prot = prototypes.create_prototype(prot)
+
+```
+
+Possible keywords are:
+ prototype_key (str): name of this prototype. This is used when storing prototypes and should
+ be unique. This should always be defined but for prototypes defined in modules, the
+ variable holding the prototype dict will become the prototype_key if it's not explicitly
+ given.
+ prototype_desc (str, optional): describes prototype in listings
+ prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes
+ supported are 'edit' and 'use'.
+ prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype
+ in listings
+ prototype_parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or
+ a list of parents, for multiple left-to-right inheritance.
+ prototype: Deprecated. Same meaning as 'parent'.
+
+ typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use
+ `settings.BASE_OBJECT_TYPECLASS`
+ key (str or callable, optional): the name of the spawned object. If not given this will set to a
+ random hash
+ location (obj, str or callable, optional): location of the object - a valid object or #dbref
+ home (obj, str or callable, optional): valid object or #dbref
+ destination (obj, str or callable, optional): only valid for exits (object or #dbref)
+
+ permissions (str, list or callable, optional): which permissions for spawned object to have
+ locks (str or callable, optional): lock-string for the spawned object
+ aliases (str, list or callable, optional): Aliases for the spawned object
+ exec (str or callable, optional): this is a string of python code to execute or a list of such
+ codes. This can be used e.g. to trigger custom handlers on the object. The execution
+ namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit
+ this functionality to Developer/superusers. Usually it's better to use callables or
+ prototypefuncs instead of this.
+ tags (str, tuple, list or callable, optional): string or list of strings or tuples
+ `(tagstr, category)`. Plain strings will be result in tags with no category (default tags).
+ attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This
+ form allows more complex Attributes to be set. Tuples at least specify `(key, value)`
+ but can also specify up to `(key, value, category, lockstring)`. If you want to specify a
+ lockstring but not a category, set the category to `None`.
+ ndb_<name> (any): value of a nattribute (ndb_ is stripped) - this is of limited use.
+ other (any): any other name is interpreted as the key of an Attribute with
+ its value. Such Attributes have no categories.
+
+Each value can also be a callable that takes no arguments. It should
+return the value to enter into the field and will be called every time
+the prototype is used to spawn an object. Note, if you want to store
+a callable in an Attribute, embed it in a tuple to the `args` keyword.
+
+By specifying the "prototype_parent" key, the prototype becomes a child of
+the given prototype, inheritng all prototype slots it does not explicitly
+define itself, while overloading those that it does specify.
+
+```python
+import random
+
+
+{
+ "prototype_key": "goblin_wizard",
+ "prototype_parent": "GOBLIN",
+ "key": "goblin wizard",
+ "spells": ["fire ball", "lighting bolt"]
+ }
+
+GOBLIN_ARCHER = {
+ "prototype_parent": "GOBLIN",
+ "key": "goblin archer",
+ "attack_skill": (random, (5, 10))"
+ "attacks": ["short bow"]
+}
+```
+
+One can also have multiple prototypes. These are inherited from the
+left, with the ones further to the right taking precedence.
+
+```python
+ARCHWIZARD = {
+ "attack": ["archwizard staff", "eye of doom"]
+
+GOBLIN_ARCHWIZARD = {
+ "key" : "goblin archwizard"
+ "prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD"),
+}
+```
+
+The *goblin archwizard* will have some different attacks, but will
+otherwise have the same spells as a *goblin wizard* who in turn shares
+many traits with a normal *goblin*.
+
+
+Storage mechanism:
+
+This sets up a central storage for prototypes. The idea is to make these
+available in a repository for buildiers to use. Each prototype is stored
+in a Script so that it can be tagged for quick sorting/finding and locked for limiting
+access.
+
+This system also takes into consideration prototypes defined and stored in modules.
+Such prototypes are considered 'read-only' to the system and can only be modified
+in code. To replace a default prototype, add the same-name prototype in a
+custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default
+prototype, override its name with an empty dict.
+
+
+"""
+
+
+importcopy
+importhashlib
+importtime
+
+fromdjango.confimportsettings
+
+importevennia
+fromevennia.objects.modelsimportObjectDB
+fromevennia.utilsimportlogger
+fromevennia.utils.utilsimportmake_iter,is_iter
+fromevennia.prototypesimportprototypesasprotlib
+fromevennia.prototypes.prototypesimport(
+ value_to_obj,
+ value_to_obj_or_any,
+ init_spawn_value,
+ PROTOTYPE_TAG_CATEGORY,
+)
+
+
+_CREATE_OBJECT_KWARGS=("key","location","home","destination")
+_PROTOTYPE_META_NAMES=("prototype_key","prototype_desc","prototype_tags","prototype_locks")
+_PROTOTYPE_ROOT_NAMES=(
+ "typeclass",
+ "key",
+ "aliases",
+ "attrs",
+ "tags",
+ "locks",
+ "permissions",
+ "location",
+ "home",
+ "destination",
+)
+_NON_CREATE_KWARGS=_CREATE_OBJECT_KWARGS+_PROTOTYPE_META_NAMES
+
+
+
+
+
+# Helper
+
+
+def_get_prototype(inprot,protparents,uninherited=None,_workprot=None):
+ """
+ Recursively traverse a prototype dictionary, including multiple
+ inheritance. Use validate_prototype before this, we don't check
+ for infinite recursion here.
+
+ Args:
+ inprot (dict): Prototype dict (the individual prototype, with no inheritance included).
+ protparents (dict): Available protparents, keyed by prototype_key.
+ uninherited (dict): Parts of prototype to not inherit.
+ _workprot (dict, optional): Work dict for the recursive algorithm.
+
+ Returns:
+ merged (dict): A prototype where parent's have been merged as needed (the
+ `prototype_parent` key is removed).
+
+ """
+
+ def_inherit_tags(old_tags,new_tags):
+ old={(tup[0],tup[1]):tupfortupinold_tags}
+ new={(tup[0],tup[1]):tupfortupinnew_tags}
+ old.update(new)
+ returnlist(old.values())
+
+ def_inherit_attrs(old_attrs,new_attrs):
+ old={(tup[0],tup[2]):tupfortupinold_attrs}
+ new={(tup[0],tup[2]):tupfortupinnew_attrs}
+ old.update(new)
+ returnlist(old.values())
+
+ _workprot={}if_workprotisNoneelse_workprot
+ if"prototype_parent"ininprot:
+ # move backwards through the inheritance
+ forprototypeinmake_iter(inprot["prototype_parent"]):
+ # Build the prot dictionary in reverse order, overloading
+ new_prot=_get_prototype(
+ protparents.get(prototype.lower(),{}),protparents,_workprot=_workprot
+ )
+
+ # attrs, tags have internal structure that should be inherited separately
+ new_prot["attrs"]=_inherit_attrs(
+ _workprot.get("attrs",{}),new_prot.get("attrs",{})
+ )
+ new_prot["tags"]=_inherit_tags(_workprot.get("tags",{}),new_prot.get("tags",{}))
+
+ _workprot.update(new_prot)
+ # the inprot represents a higher level (a child prot), which should override parents
+
+ inprot["attrs"]=_inherit_attrs(_workprot.get("attrs",{}),inprot.get("attrs",{}))
+ inprot["tags"]=_inherit_tags(_workprot.get("tags",{}),inprot.get("tags",{}))
+ _workprot.update(inprot)
+ ifuninherited:
+ # put back the parts that should not be inherited
+ _workprot.update(uninherited)
+ _workprot.pop("prototype_parent",None)# we don't need this for spawning
+ return_workprot
+
+
+
[docs]defflatten_prototype(prototype,validate=False):
+ """
+ Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been
+ merged into a final prototype.
+
+ Args:
+ prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed.
+ validate (bool, optional): Validate for valid keys etc.
+
+ Returns:
+ flattened (dict): The final, flattened prototype.
+
+ """
+
+ ifprototype:
+ prototype=protlib.homogenize_prototype(prototype)
+ protparents={prot["prototype_key"].lower():protforprotinprotlib.search_prototype()}
+ protlib.validate_prototype(
+ prototype,None,protparents,is_prototype_base=validate,strict=validate
+ )
+ return_get_prototype(
+ prototype,protparents,uninherited={"prototype_key":prototype.get("prototype_key")}
+ )
+ return{}
+
+
+# obj-related prototype functions
+
+
+
[docs]defprototype_from_object(obj):
+ """
+ Guess a minimal prototype from an existing object.
+
+ Args:
+ obj (Object): An object to analyze.
+
+ Returns:
+ prototype (dict): A prototype estimating the current state of the object.
+
+ """
+ # first, check if this object already has a prototype
+
+ prot=obj.tags.get(category=PROTOTYPE_TAG_CATEGORY,return_list=True)
+ ifprot:
+ prot=protlib.search_prototype(prot[0])
+
+ ifnotprotorlen(prot)>1:
+ # no unambiguous prototype found - build new prototype
+ prot={}
+ prot["prototype_key"]="From-Object-{}-{}".format(
+ obj.key,hashlib.md5(bytes(str(time.time()),"utf-8")).hexdigest()[:7]
+ )
+ prot["prototype_desc"]="Built from {}".format(str(obj))
+ prot["prototype_locks"]="spawn:all();edit:all()"
+ prot["prototype_tags"]=[]
+ else:
+ prot=prot[0]
+
+ prot["key"]=obj.db_keyorhashlib.md5(bytes(str(time.time()),"utf-8")).hexdigest()[:6]
+ prot["typeclass"]=obj.db_typeclass_path
+
+ location=obj.db_location
+ iflocation:
+ prot["location"]=location.dbref
+ home=obj.db_home
+ ifhome:
+ prot["home"]=home.dbref
+ destination=obj.db_destination
+ ifdestination:
+ prot["destination"]=destination.dbref
+ locks=obj.locks.all()
+ iflocks:
+ prot["locks"]=";".join(locks)
+ perms=obj.permissions.get(return_list=True)
+ ifperms:
+ prot["permissions"]=make_iter(perms)
+ aliases=obj.aliases.get(return_list=True)
+ ifaliases:
+ prot["aliases"]=aliases
+ tags=sorted(
+ [(tag.db_key,tag.db_category,tag.db_data)fortaginobj.tags.all(return_objs=True)]
+ )
+ iftags:
+ prot["tags"]=tags
+ attrs=sorted(
+ [
+ (attr.key,attr.value,attr.category,";".join(attr.locks.all()))
+ forattrinobj.attributes.all()
+ ]
+ )
+ ifattrs:
+ prot["attrs"]=attrs
+
+ returnprot
+
+
+
[docs]defprototype_diff(prototype1,prototype2,maxdepth=2,homogenize=False,implicit_keep=False):
+ """
+ A 'detailed' diff specifies differences down to individual sub-sections
+ of the prototype, like individual attributes, permissions etc. It is used
+ by the menu to allow a user to customize what should be kept.
+
+ Args:
+ prototype1 (dict): Original prototype.
+ prototype2 (dict): Comparison prototype.
+ maxdepth (int, optional): The maximum depth into the diff we go before treating the elements
+ of iterables as individual entities to compare. This is important since a single
+ attr/tag (for example) are represented by a tuple.
+ homogenize (bool, optional): Auto-homogenize both prototypes for the best comparison.
+ This is most useful for displaying.
+ implicit_keep (bool, optional): If set, the resulting diff will assume KEEP unless the new
+ prototype explicitly change them. That is, if a key exists in `prototype1` and
+ not in `prototype2`, it will not be REMOVEd but set to KEEP instead. This is particularly
+ useful for auto-generated prototypes when updating objects.
+
+ Returns:
+ diff (dict): A structure detailing how to convert prototype1 to prototype2. All
+ nested structures are dicts with keys matching either the prototype's matching
+ key or the first element in the tuple describing the prototype value (so for
+ a tag tuple `(tagname, category)` the second-level key in the diff would be tagname).
+ The the bottom level of the diff consist of tuples `(old, new, instruction)`, where
+ instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP".
+
+ """
+ _unset=Unset()
+
+ def_recursive_diff(old,new,depth=0):
+
+ old_type=type(old)
+ new_type=type(new)
+
+ ifold_type==new_typeandnot(oldornew):
+ # both old and new are unset, like [] or None
+ return(None,None,"KEEP")
+ ifold_type!=new_type:
+ ifoldandnotnew:
+ ifdepth<maxdepthandold_type==dict:
+ return{key:(part,None,"REMOVE")forkey,partinold.items()}
+ elifdepth<maxdepthandis_iter(old):
+ return{
+ part[0]ifis_iter(part)elsepart:(part,None,"REMOVE")forpartinold
+ }
+ ifisinstance(new,Unset)andimplicit_keep:
+ # the new does not define any change, use implicit-keep
+ return(old,None,"KEEP")
+ return(old,new,"REMOVE")
+ elifnotoldandnew:
+ ifdepth<maxdepthandnew_type==dict:
+ return{key:(None,part,"ADD")forkey,partinnew.items()}
+ elifdepth<maxdepthandis_iter(new):
+ return{part[0]ifis_iter(part)elsepart:(None,part,"ADD")forpartinnew}
+ return(old,new,"ADD")
+ else:
+ # this condition should not occur in a standard diff
+ return(old,new,"UPDATE")
+ elifdepth<maxdepthandnew_type==dict:
+ all_keys=set(list(old.keys())+list(new.keys()))
+ return{
+ key:_recursive_diff(old.get(key,_unset),new.get(key,_unset),depth=depth+1)
+ forkeyinall_keys
+ }
+ elifdepth<maxdepthandis_iter(new):
+ old_map={part[0]ifis_iter(part)elsepart:partforpartinold}
+ new_map={part[0]ifis_iter(part)elsepart:partforpartinnew}
+ all_keys=set(list(old_map.keys())+list(new_map.keys()))
+ return{
+ key:_recursive_diff(
+ old_map.get(key,_unset),new_map.get(key,_unset),depth=depth+1
+ )
+ forkeyinall_keys
+ }
+ elifold!=new:
+ return(old,new,"UPDATE")
+ else:
+ return(old,new,"KEEP")
+
+ prot1=protlib.homogenize_prototype(prototype1)ifhomogenizeelseprototype1
+ prot2=protlib.homogenize_prototype(prototype2)ifhomogenizeelseprototype2
+
+ diff=_recursive_diff(prot1,prot2)
+
+ returndiff
+
+
+
[docs]defflatten_diff(diff):
+ """
+ For spawning, a 'detailed' diff is not necessary, rather we just want instructions on how to
+ handle each root key.
+
+ Args:
+ diff (dict): Diff produced by `prototype_diff` and
+ possibly modified by the user. Note that also a pre-flattened diff will come out
+ unchanged by this function.
+
+ Returns:
+ flattened_diff (dict): A flat structure detailing how to operate on each
+ root component of the prototype.
+
+ Notes:
+ The flattened diff has the following possible instructions:
+ UPDATE, REPLACE, REMOVE
+ Many of the detailed diff's values can hold nested structures with their own
+ individual instructions. A detailed diff can have the following instructions:
+ REMOVE, ADD, UPDATE, KEEP
+ Here's how they are translated:
+ - All REMOVE -> REMOVE
+ - All ADD|UPDATE -> UPDATE
+ - All KEEP -> KEEP
+ - Mix KEEP, UPDATE, ADD -> UPDATE
+ - Mix REMOVE, KEEP, UPDATE, ADD -> REPLACE
+ """
+
+ valid_instructions=("KEEP","REMOVE","ADD","UPDATE")
+
+ def_get_all_nested_diff_instructions(diffpart):
+ "Started for each root key, returns all instructions nested under it"
+ out=[]
+ typ=type(diffpart)
+ iftyp==tupleandlen(diffpart)==3anddiffpart[2]invalid_instructions:
+ out=[diffpart[2]]
+ eliftyp==dict:
+ # all other are dicts
+ forvalindiffpart.values():
+ out.extend(_get_all_nested_diff_instructions(val))
+ else:
+ raiseRuntimeError(
+ "Diff contains non-dicts that are not on the "
+ "form (old, new, inst): {}".format(diffpart)
+ )
+ returnout
+
+ flat_diff={}
+
+ # flatten diff based on rules
+ forrootkey,diffpartindiff.items():
+ insts=_get_all_nested_diff_instructions(diffpart)
+ ifall(inst=="KEEP"forinstininsts):
+ rootinst="KEEP"
+ elifall(instin("ADD","UPDATE")forinstininsts):
+ rootinst="UPDATE"
+ elifall(inst=="REMOVE"forinstininsts):
+ rootinst="REMOVE"
+ elif"REMOVE"ininsts:
+ rootinst="REPLACE"
+ else:
+ rootinst="UPDATE"
+
+ flat_diff[rootkey]=rootinst
+
+ returnflat_diff
+
+
+
[docs]defprototype_diff_from_object(prototype,obj,implicit_keep=True):
+ """
+ Get a simple diff for a prototype compared to an object which may or may not already have a
+ prototype (or has one but changed locally). For more complex migratations a manual diff may be
+ needed.
+
+ Args:
+ prototype (dict): New prototype.
+ obj (Object): Object to compare prototype against.
+
+ Returns:
+ diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...}
+ obj_prototype (dict): The prototype calculated for the given object. The diff is how to
+ convert this prototype into the new prototype.
+ implicit_keep (bool, optional): This is usually what one wants for object updating. When
+ set, this means the prototype diff will assume KEEP on differences
+ between the object-generated prototype and that which is not explicitly set in the
+ new prototype. This means e.g. that even though the object has a location, and the
+ prototype does not specify the location, it will not be unset.
+
+ Notes:
+ The `diff` is on the following form:
+
+ {"key": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"),
+ "attrs": {"attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"),
+ "attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), ...},
+ "aliases": {"aliasname": (old, new, "KEEP...", ...},
+ ... }
+
+ """
+ obj_prototype=prototype_from_object(obj)
+ diff=prototype_diff(
+ obj_prototype,protlib.homogenize_prototype(prototype),implicit_keep=implicit_keep
+ )
+ returndiff,obj_prototype
[docs]defbatch_update_objects_with_prototype(prototype,diff=None,objects=None,exact=False):
+ """
+ Update existing objects with the latest version of the prototype.
+
+ Args:
+ prototype (str or dict): Either the `prototype_key` to use or the
+ prototype dict itself.
+ diff (dict, optional): This a diff structure that describes how to update the protototype.
+ If not given this will be constructed from the first object found.
+ objects (list, optional): List of objects to update. If not given, query for these
+ objects using the prototype's `prototype_key`.
+ exact (bool, optional): By default (`False`), keys not explicitly in the prototype will
+ not be applied to the object, but will be retained as-is. This is usually what is
+ expected - for example, one usually do not want to remove the object's location even
+ if it's not set in the prototype. With `exact=True`, all un-specified properties of the
+ objects will be removed if they exist. This will lead to a more accurate 1:1 correlation
+ between the object and the prototype but is usually impractical.
+ Returns:
+ changed (int): The number of objects that had changes applied to them.
+
+ """
+ prototype=protlib.homogenize_prototype(prototype)
+
+ ifisinstance(prototype,str):
+ new_prototype=protlib.search_prototype(prototype)
+ else:
+ new_prototype=prototype
+
+ prototype_key=new_prototype["prototype_key"]
+
+ ifnotobjects:
+ objects=ObjectDB.objects.get_by_tag(prototype_key,category=PROTOTYPE_TAG_CATEGORY)
+
+ ifnotobjects:
+ return0
+
+ ifnotdiff:
+ diff,_=prototype_diff_from_object(new_prototype,objects[0])
+
+ # make sure the diff is flattened
+ diff=flatten_diff(diff)
+
+ changed=0
+ forobjinobjects:
+ do_save=False
+
+ old_prot_key=obj.tags.get(category=PROTOTYPE_TAG_CATEGORY,return_list=True)
+ old_prot_key=old_prot_key[0]ifold_prot_keyelseNone
+
+ try:
+ forkey,directiveindiff.items():
+
+ ifkeynotinnew_prototypeandnotexact:
+ # we don't update the object if the prototype does not actually
+ # contain the key (the diff will report REMOVE but we ignore it
+ # since exact=False)
+ continue
+
+ ifdirectivein("UPDATE","REPLACE"):
+
+ ifkeyin_PROTOTYPE_META_NAMES:
+ # prototype meta keys are not stored on-object
+ continue
+
+ val=new_prototype[key]
+ do_save=True
+
+ ifkey=="key":
+ obj.db_key=init_spawn_value(val,str)
+ elifkey=="typeclass":
+ obj.db_typeclass_path=init_spawn_value(val,str)
+ elifkey=="location":
+ obj.db_location=init_spawn_value(val,value_to_obj)
+ elifkey=="home":
+ obj.db_home=init_spawn_value(val,value_to_obj)
+ elifkey=="destination":
+ obj.db_destination=init_spawn_value(val,value_to_obj)
+ elifkey=="locks":
+ ifdirective=="REPLACE":
+ obj.locks.clear()
+ obj.locks.add(init_spawn_value(val,str))
+ elifkey=="permissions":
+ ifdirective=="REPLACE":
+ obj.permissions.clear()
+ obj.permissions.batch_add(*(init_spawn_value(perm,str)forperminval))
+ elifkey=="aliases":
+ ifdirective=="REPLACE":
+ obj.aliases.clear()
+ obj.aliases.batch_add(*(init_spawn_value(alias,str)foraliasinval))
+ elifkey=="tags":
+ ifdirective=="REPLACE":
+ obj.tags.clear()
+ obj.tags.batch_add(
+ *(
+ (init_spawn_value(ttag,str),tcategory,tdata)
+ forttag,tcategory,tdatainval
+ )
+ )
+ elifkey=="attrs":
+ ifdirective=="REPLACE":
+ obj.attributes.clear()
+ obj.attributes.batch_add(
+ *(
+ (
+ init_spawn_value(akey,str),
+ init_spawn_value(aval,value_to_obj),
+ acategory,
+ alocks,
+ )
+ forakey,aval,acategory,alocksinval
+ )
+ )
+ elifkey=="exec":
+ # we don't auto-rerun exec statements, it would be huge security risk!
+ pass
+ else:
+ obj.attributes.add(key,init_spawn_value(val,value_to_obj))
+ elifdirective=="REMOVE":
+ do_save=True
+ ifkey=="key":
+ obj.db_key=""
+ elifkey=="typeclass":
+ # fall back to default
+ obj.db_typeclass_path=settings.BASE_OBJECT_TYPECLASS
+ elifkey=="location":
+ obj.db_location=None
+ elifkey=="home":
+ obj.db_home=None
+ elifkey=="destination":
+ obj.db_destination=None
+ elifkey=="locks":
+ obj.locks.clear()
+ elifkey=="permissions":
+ obj.permissions.clear()
+ elifkey=="aliases":
+ obj.aliases.clear()
+ elifkey=="tags":
+ obj.tags.clear()
+ elifkey=="attrs":
+ obj.attributes.clear()
+ elifkey=="exec":
+ # we don't auto-rerun exec statements, it would be huge security risk!
+ pass
+ else:
+ obj.attributes.remove(key)
+ exceptException:
+ logger.log_trace(f"Failed to apply prototype '{prototype_key}' to {obj}.")
+ finally:
+ # we must always make sure to re-add the prototype tag
+ obj.tags.clear(category=PROTOTYPE_TAG_CATEGORY)
+ obj.tags.add(prototype_key,category=PROTOTYPE_TAG_CATEGORY)
+
+ ifdo_save:
+ changed+=1
+ obj.save()
+
+ returnchanged
+
+
+
[docs]defbatch_create_object(*objparams):
+ """
+ This is a cut-down version of the create_object() function,
+ optimized for speed. It does NOT check and convert various input
+ so make sure the spawned Typeclass works before using this!
+
+ Args:
+ objsparams (tuple): Each paremter tuple will create one object instance using the parameters
+ within.
+ The parameters should be given in the following order:
+ - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`.
+ - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`.
+ - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`.
+ - `aliases` (list): A list of alias strings for
+ adding with `new_object.aliases.batch_add(*aliases)`.
+ - `nattributes` (list): list of tuples `(key, value)` to be loop-added to
+ add with `new_obj.nattributes.add(*tuple)`.
+ - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for
+ adding with `new_obj.attributes.batch_add(*attributes)`.
+ - `tags` (list): list of tuples `(key, category)` for adding
+ with `new_obj.tags.batch_add(*tags)`.
+ - `execs` (list): Code strings to execute together with the creation
+ of each object. They will be executed with `evennia` and `obj`
+ (the newly created object) available in the namespace. Execution
+ will happend after all other properties have been assigned and
+ is intended for calling custom handlers etc.
+
+ Returns:
+ objects (list): A list of created objects
+
+ Notes:
+ The `exec` list will execute arbitrary python code so don't allow this to be available to
+ unprivileged users!
+
+ """
+
+ # bulk create all objects in one go
+
+ # unfortunately this doesn't work since bulk_create doesn't creates pks;
+ # the result would be duplicate objects at the next stage, so we comment
+ # it out for now:
+ # dbobjs = _ObjectDB.objects.bulk_create(dbobjs)
+
+ objs=[]
+ forobjparaminobjparams:
+
+ obj=ObjectDB(**objparam[0])
+
+ # setup
+ obj._createdict={
+ "permissions":make_iter(objparam[1]),
+ "locks":objparam[2],
+ "aliases":make_iter(objparam[3]),
+ "nattributes":objparam[4],
+ "attributes":objparam[5],
+ "tags":make_iter(objparam[6]),
+ }
+ # this triggers all hooks
+ obj.save()
+ # run eventual extra code
+ forcodeinobjparam[7]:
+ ifcode:
+ exec(code,{},{"evennia":evennia,"obj":obj})
+ objs.append(obj)
+ returnobjs
+
+
+# Spawner mechanism
+
+
+
[docs]defspawn(*prototypes,**kwargs):
+ """
+ Spawn a number of prototyped objects.
+
+ Args:
+ prototypes (str or dict): Each argument should either be a
+ prototype_key (will be used to find the prototype) or a full prototype
+ dictionary. These will be batched-spawned as one object each.
+ Keyword Args:
+ prototype_modules (str or list): A python-path to a prototype
+ module, or a list of such paths. These will be used to build
+ the global protparents dictionary accessible by the input
+ prototypes. If not given, it will instead look for modules
+ defined by settings.PROTOTYPE_MODULES.
+ prototype_parents (dict): A dictionary holding a custom
+ prototype-parent dictionary. Will overload same-named
+ prototypes from prototype_modules.
+ return_parents (bool): Return a dict of the entire prototype-parent tree
+ available to this prototype (no object creation happens). This is a
+ merged result between the globally found protparents and whatever
+ custom `prototype_parents` are given to this function.
+ only_validate (bool): Only run validation of prototype/parents
+ (no object creation) and return the create-kwargs.
+
+ Returns:
+ object (Object, dict or list): Spawned object(s). If `only_validate` is given, return
+ a list of the creation kwargs to build the object(s) without actually creating it. If
+ `return_parents` is set, instead return dict of prototype parents.
+
+ """
+ # search string (=prototype_key) from input
+ prototypes=[
+ protlib.search_prototype(prot,require_single=True)[0]ifisinstance(prot,str)elseprot
+ forprotinprototypes
+ ]
+
+ # get available protparents
+ protparents={prot["prototype_key"].lower():protforprotinprotlib.search_prototype()}
+
+ ifnotkwargs.get("only_validate"):
+ # homogenization to be more lenient about prototype format when entering the prototype manually
+ prototypes=[protlib.homogenize_prototype(prot)forprotinprototypes]
+
+ # overload module's protparents with specifically given protparents
+ # we allow prototype_key to be the key of the protparent dict, to allow for module-level
+ # prototype imports. We need to insert prototype_key in this case
+ forkey,protparentinkwargs.get("prototype_parents",{}).items():
+ key=str(key).lower()
+ protparent["prototype_key"]=str(protparent.get("prototype_key",key)).lower()
+ protparents[key]=protparent
+
+ if"return_parents"inkwargs:
+ # only return the parents
+ returncopy.deepcopy(protparents)
+
+ objsparams=[]
+ forprototypeinprototypes:
+
+ protlib.validate_prototype(prototype,None,protparents,is_prototype_base=True)
+ prot=_get_prototype(
+ prototype,protparents,uninherited={"prototype_key":prototype.get("prototype_key")}
+ )
+ ifnotprot:
+ continue
+
+ # extract the keyword args we need to create the object itself. If we get a callable,
+ # call that to get the value (don't catch errors)
+ create_kwargs={}
+ # we must always add a key, so if not given we use a shortened md5 hash. There is a (small)
+ # chance this is not unique but it should usually not be a problem.
+ val=prot.pop(
+ "key",
+ "Spawned-{}".format(hashlib.md5(bytes(str(time.time()),"utf-8")).hexdigest()[:6]),
+ )
+ create_kwargs["db_key"]=init_spawn_value(val,str)
+
+ val=prot.pop("location",None)
+ create_kwargs["db_location"]=init_spawn_value(val,value_to_obj)
+
+ val=prot.pop("home",settings.DEFAULT_HOME)
+ create_kwargs["db_home"]=init_spawn_value(val,value_to_obj)
+
+ val=prot.pop("destination",None)
+ create_kwargs["db_destination"]=init_spawn_value(val,value_to_obj)
+
+ val=prot.pop("typeclass",settings.BASE_OBJECT_TYPECLASS)
+ create_kwargs["db_typeclass_path"]=init_spawn_value(val,str)
+
+ # extract calls to handlers
+ val=prot.pop("permissions",[])
+ permission_string=init_spawn_value(val,make_iter)
+ val=prot.pop("locks","")
+ lock_string=init_spawn_value(val,str)
+ val=prot.pop("aliases",[])
+ alias_string=init_spawn_value(val,make_iter)
+
+ val=prot.pop("tags",[])
+ tags=[]
+ for(tag,category,data)inval:
+ tags.append((init_spawn_value(tag,str),category,data))
+
+ prototype_key=prototype.get("prototype_key",None)
+ ifprototype_key:
+ # we make sure to add a tag identifying which prototype created this object
+ tags.append((prototype_key,PROTOTYPE_TAG_CATEGORY))
+
+ val=prot.pop("exec","")
+ execs=init_spawn_value(val,make_iter)
+
+ # extract ndb assignments
+ nattributes=dict(
+ (key.split("_",1)[1],init_spawn_value(val,value_to_obj))
+ forkey,valinprot.items()
+ ifkey.startswith("ndb_")
+ )
+
+ # the rest are attribute tuples (attrname, value, category, locks)
+ val=make_iter(prot.pop("attrs",[]))
+ attributes=[]
+ for(attrname,value,category,locks)inval:
+ attributes.append((attrname,init_spawn_value(value),category,locks))
+
+ simple_attributes=[]
+ forkey,valuein(
+ (key,value)forkey,valueinprot.items()ifnot(key.startswith("ndb_"))
+ ):
+ # we don't support categories, nor locks for simple attributes
+ ifkeyin_PROTOTYPE_META_NAMES:
+ continue
+ else:
+ simple_attributes.append(
+ (key,init_spawn_value(value,value_to_obj_or_any),None,None)
+ )
+
+ attributes=attributes+simple_attributes
+ attributes=[tupfortupinattributesifnottup[0]in_NON_CREATE_KWARGS]
+
+ # pack for call into _batch_create_object
+ objsparams.append(
+ (
+ create_kwargs,
+ permission_string,
+ lock_string,
+ alias_string,
+ nattributes,
+ attributes,
+ tags,
+ execs,
+ )
+ )
+
+ ifkwargs.get("only_validate"):
+ returnobjsparams
+ returnbatch_create_object(*objsparams)
+#
+# This sets up how models are displayed
+# in the web admin interface.
+#
+fromdjango.confimportsettings
+
+fromevennia.typeclasses.adminimportAttributeInline,TagInline
+
+fromevennia.scripts.modelsimportScriptDB
+fromdjango.contribimportadmin
+
+
+
[docs]defsave_model(self,request,obj,form,change):
+ """
+ Model-save hook.
+
+ Args:
+ request (Request): Incoming request.
+ obj (Object): Database object.
+ form (Form): Form instance.
+ change (bool): If this is a change or a new object.
+
+ """
+ obj.save()
+ ifnotchange:
+ # adding a new object
+ # have to call init with typeclass passed to it
+ obj.set_class_from_typeclass(typeclass_path=obj.db_typeclass_path)
[docs]classScriptDBManager(TypedObjectManager):
+ """
+ This Scriptmanager implements methods for searching
+ and manipulating Scripts directly from the database.
+
+ Evennia-specific search methods (will return Typeclasses or
+ lists of Typeclasses, whereas Django-general methods will return
+ Querysets or database objects).
+
+ dbref (converter)
+ get_id (or dbref_search)
+ get_dbref_range
+ object_totals
+ typeclass_search
+ get_all_scripts_on_obj
+ get_all_scripts
+ delete_script
+ remove_non_persistent
+ validate
+ script_search (equivalent to evennia.search_script)
+ copy_script
+
+ """
+
+
[docs]defget_all_scripts_on_obj(self,obj,key=None):
+ """
+ Find all Scripts related to a particular object.
+
+ Args:
+ obj (Object): Object whose Scripts we are looking for.
+ key (str, optional): Script identifier - can be given as a
+ dbref or name string. If given, only scripts matching the
+ key on the object will be returned.
+ Returns:
+ matches (list): Matching scripts.
+
+ """
+ ifnotobj:
+ return[]
+ account=_GA(_GA(obj,"__dbclass__"),"__name__")=="AccountDB"
+ ifkey:
+ dbref=self.dbref(key)
+ ifdbrefordbref==0:
+ ifaccount:
+ returnself.filter(db_account=obj,id=dbref)
+ else:
+ returnself.filter(db_obj=obj,id=dbref)
+ elifaccount:
+ returnself.filter(db_account=obj,db_key=key)
+ else:
+ returnself.filter(db_obj=obj,db_key=key)
+ elifaccount:
+ returnself.filter(db_account=obj)
+ else:
+ returnself.filter(db_obj=obj)
+
+
[docs]defget_all_scripts(self,key=None):
+ """
+ Get all scripts in the database.
+
+ Args:
+ key (str or int, optional): Restrict result to only those
+ with matching key or dbref.
+
+ Returns:
+ scripts (list): All scripts found, or those matching `key`.
+
+ """
+ ifkey:
+ script=[]
+ dbref=self.dbref(key)
+ ifdbref:
+ returnself.filter(id=dbref)
+ returnself.filter(db_key__iexact=key.strip())
+ returnself.all()
+
+
[docs]defdelete_script(self,dbref):
+ """
+ This stops and deletes a specific script directly from the
+ script database.
+
+ Args:
+ dbref (int): Database unique id.
+
+ Notes:
+ This might be needed for global scripts not tied to a
+ specific game object
+
+ """
+ scripts=self.get_id(dbref)
+ forscriptinmake_iter(scripts):
+ script.stop()
+
+
[docs]defremove_non_persistent(self,obj=None):
+ """
+ This cleans up the script database of all non-persistent
+ scripts. It is called every time the server restarts.
+
+ Args:
+ obj (Object, optional): Only remove non-persistent scripts
+ assigned to this object.
+
+ """
+ ifobj:
+ to_stop=self.filter(db_obj=obj,db_persistent=False,db_is_active=True)
+ to_delete=self.filter(db_obj=obj,db_persistent=False,db_is_active=False)
+ else:
+ to_stop=self.filter(db_persistent=False,db_is_active=True)
+ to_delete=self.filter(db_persistent=False,db_is_active=False)
+ nr_deleted=to_stop.count()+to_delete.count()
+ forscriptinto_stop:
+ script.stop()
+ forscriptinto_delete:
+ script.delete()
+ returnnr_deleted
+
+
[docs]defvalidate(self,scripts=None,obj=None,key=None,dbref=None,init_mode=None):
+ """
+ This will step through the script database and make sure
+ all objects run scripts that are still valid in the context
+ they are in. This is called by the game engine at regular
+ intervals but can also be initiated by player scripts.
+
+ Only one of the arguments are supposed to be supplied
+ at a time, since they are exclusive to each other.
+
+ Args:
+ scripts (list, optional): A list of script objects to
+ validate.
+ obj (Object, optional): Validate only scripts defined on
+ this object.
+ key (str): Validate only scripts with this key.
+ dbref (int): Validate only the single script with this
+ particular id.
+ init_mode (str, optional): This is used during server
+ upstart and can have three values:
+ - `None` (no init mode). Called during run.
+ - `"reset"` - server reboot. Kill non-persistent scripts
+ - `"reload"` - server reload. Keep non-persistent scripts.
+ Returns:
+ nr_started, nr_stopped (tuple): Statistics on how many objects
+ where started and stopped.
+
+ Notes:
+ This method also makes sure start any scripts it validates
+ which should be harmless, since already-active scripts have
+ the property 'is_running' set and will be skipped.
+
+ """
+
+ # we store a variable that tracks if we are calling a
+ # validation from within another validation (avoids
+ # loops).
+
+ globalVALIDATE_ITERATION
+ ifVALIDATE_ITERATION>0:
+ # we are in a nested validation. Exit.
+ VALIDATE_ITERATION-=1
+ returnNone,None
+ VALIDATE_ITERATION+=1
+
+ # not in a validation - loop. Validate as normal.
+
+ nr_started=0
+ nr_stopped=0
+
+ ifinit_mode:
+ ifinit_mode=="reset":
+ # special mode when server starts or object logs in.
+ # This deletes all non-persistent scripts from database
+ nr_stopped+=self.remove_non_persistent(obj=obj)
+ # turn off the activity flag for all remaining scripts
+ scripts=self.get_all_scripts()
+ forscriptinscripts:
+ script.is_active=False
+
+ elifnotscripts:
+ # normal operation
+ ifdbrefandself.dbref(dbref,reqhash=False):
+ scripts=self.get_id(dbref)
+ elifobj:
+ scripts=self.get_all_scripts_on_obj(obj,key=key)
+ else:
+ scripts=self.get_all_scripts(key=key)
+
+ ifnotscripts:
+ # no scripts available to validate
+ VALIDATE_ITERATION-=1
+ returnNone,None
+
+ forscriptinscripts:
+ ifscript.is_valid():
+ nr_started+=script.start(force_restart=init_mode)
+ else:
+ script.stop()
+ nr_stopped+=1
+ VALIDATE_ITERATION-=1
+ returnnr_started,nr_stopped
+
+
[docs]defsearch_script(self,ostring,obj=None,only_timed=False,typeclass=None):
+ """
+ Search for a particular script.
+
+ Args:
+ ostring (str): Search criterion - a script dbef or key.
+ obj (Object, optional): Limit search to scripts defined on
+ this object
+ only_timed (bool): Limit search only to scripts that run
+ on a timer.
+ typeclass (class or str): Typeclass or path to typeclass.
+
+ """
+
+ ostring=ostring.strip()
+
+ dbref=self.dbref(ostring)
+ ifdbref:
+ # this is a dbref, try to find the script directly
+ dbref_match=self.dbref_search(dbref)
+ ifdbref_matchandnot(
+ (objandobj!=dbref_match.obj)or(only_timedanddbref_match.interval)
+ ):
+ return[dbref_match]
+
+ iftypeclass:
+ ifcallable(typeclass):
+ typeclass="%s.%s"%(typeclass.__module__,typeclass.__name__)
+ else:
+ typeclass="%s"%typeclass
+
+ # not a dbref; normal search
+ obj_restriction=objandQ(db_obj=obj)orQ()
+ timed_restriction=only_timedandQ(db_interval__gt=0)orQ()
+ typeclass_restriction=typeclassandQ(db_typeclass_path=typeclass)orQ()
+ scripts=self.filter(
+ timed_restriction&obj_restriction&typeclass_restriction&Q(db_key__iexact=ostring)
+ )
+ returnscripts
+
+ # back-compatibility alias
+ script_search=search_script
+
+
[docs]defcopy_script(self,original_script,new_key=None,new_obj=None,new_locks=None):
+ """
+ Make an identical copy of the original_script.
+
+ Args:
+ original_script (Script): The Script to copy.
+ new_key (str, optional): Rename the copy.
+ new_obj (Object, optional): Place copy on different Object.
+ new_locks (str, optional): Give copy different locks from
+ the original.
+
+ Returns:
+ script_copy (Script): A new Script instance, copied from
+ the original.
+ """
+ typeclass=original_script.typeclass_path
+ new_key=new_keyifnew_keyisnotNoneelseoriginal_script.key
+ new_obj=new_objifnew_objisnotNoneelseoriginal_script.obj
+ new_locks=new_locksifnew_locksisnotNoneelseoriginal_script.db_lock_storage
+
+ fromevennia.utilsimportcreate
+
+ new_script=create.create_script(
+ typeclass,key=new_key,obj=new_obj,locks=new_locks,autostart=True
+ )
+ returnnew_script
+"""
+Scripts are entities that perform some sort of action, either only
+once or repeatedly. They can be directly linked to a particular
+Evennia Object or be stand-alonw (in the latter case it is considered
+a 'global' script). Scripts can indicate both actions related to the
+game world as well as pure behind-the-scenes events and effects.
+Everything that has a time component in the game (i.e. is not
+hard-coded at startup or directly created/controlled by players) is
+handled by Scripts.
+
+Scripts have to check for themselves that they should be applied at a
+particular moment of time; this is handled by the is_valid() hook.
+Scripts can also implement at_start and at_end hooks for preparing and
+cleaning whatever effect they have had on the game object.
+
+Common examples of uses of Scripts:
+
+- Load the default cmdset to the account object's cmdhandler
+ when logging in.
+- Switch to a different state, such as entering a text editor,
+ start combat or enter a dark room.
+- Merge a new cmdset with the default one for changing which
+ commands are available at a particular time
+- Give the account/object a time-limited bonus/effect
+
+"""
+fromdjango.confimportsettings
+fromdjango.dbimportmodels
+fromdjango.core.exceptionsimportObjectDoesNotExist
+fromevennia.typeclasses.modelsimportTypedObject
+fromevennia.scripts.managerimportScriptDBManager
+fromevennia.utils.utilsimportdbref,to_str
+
+__all__=("ScriptDB",)
+_GA=object.__getattribute__
+_SA=object.__setattr__
+
+
+# ------------------------------------------------------------
+#
+# ScriptDB
+#
+# ------------------------------------------------------------
+
+
+
[docs]classScriptDB(TypedObject):
+ """
+ The Script database representation.
+
+ The TypedObject supplies the following (inherited) properties:
+ key - main name
+ name - alias for key
+ typeclass_path - the path to the decorating typeclass
+ typeclass - auto-linked typeclass
+ date_created - time stamp of object creation
+ permissions - perm strings
+ dbref - #id of object
+ db - persistent attribute storage
+ ndb - non-persistent attribute storage
+
+ The ScriptDB adds the following properties:
+ desc - optional description of script
+ obj - the object the script is linked to, if any
+ account - the account the script is linked to (exclusive with obj)
+ interval - how often script should run
+ start_delay - if the script should start repeating right away
+ repeats - how many times the script should repeat
+ persistent - if script should survive a server reboot
+ is_active - bool if script is currently running
+
+ """
+
+ #
+ # ScriptDB Database Model setup
+ #
+ # These database fields are all set using their corresponding properties,
+ # named same as the field, but withtou the db_* prefix.
+
+ # inherited fields (from TypedObject):
+ # db_key, db_typeclass_path, db_date_created, db_permissions
+
+ # optional description.
+ db_desc=models.CharField("desc",max_length=255,blank=True)
+ # A reference to the database object affected by this Script, if any.
+ db_obj=models.ForeignKey(
+ "objects.ObjectDB",
+ null=True,
+ blank=True,
+ on_delete=models.CASCADE,
+ verbose_name="scripted object",
+ help_text="the object to store this script on, if not a global script.",
+ )
+ db_account=models.ForeignKey(
+ "accounts.AccountDB",
+ null=True,
+ blank=True,
+ on_delete=models.CASCADE,
+ verbose_name="scripted account",
+ help_text="the account to store this script on (should not be set if db_obj is set)",
+ )
+
+ # how often to run Script (secs). -1 means there is no timer
+ db_interval=models.IntegerField(
+ "interval",default=-1,help_text="how often to repeat script, in seconds. -1 means off."
+ )
+ # start script right away or wait interval seconds first
+ db_start_delay=models.BooleanField(
+ "start delay",default=False,help_text="pause interval seconds before starting."
+ )
+ # how many times this script is to be repeated, if interval!=0.
+ db_repeats=models.IntegerField("number of repeats",default=0,help_text="0 means off.")
+ # defines if this script should survive a reboot or not
+ db_persistent=models.BooleanField("survive server reboot",default=False)
+ # defines if this script has already been started in this session
+ db_is_active=models.BooleanField("script active",default=False)
+
+ # Database manager
+ objects=ScriptDBManager()
+
+ # defaults
+ __settingsclasspath__=settings.BASE_SCRIPT_TYPECLASS
+ __defaultclasspath__="evennia.scripts.scripts.DefaultScript"
+ __applabel__="scripts"
+
+ classMeta(object):
+ "Define Django meta options"
+ verbose_name="Script"
+
+ #
+ #
+ # ScriptDB class properties
+ #
+ #
+
+ # obj property
+ def__get_obj(self):
+ """
+ Property wrapper that homogenizes access to either the
+ db_account or db_obj field, using the same object property
+ name.
+
+ """
+ obj=_GA(self,"db_account")
+ ifnotobj:
+ obj=_GA(self,"db_obj")
+ returnobj
+
+ def__set_obj(self,value):
+ """
+ Set account or obj to their right database field. If
+ a dbref is given, assume ObjectDB.
+
+ """
+ try:
+ value=_GA(value,"dbobj")
+ exceptAttributeError:
+ # deprecated ...
+ pass
+ ifisinstance(value,(str,int)):
+ fromevennia.objects.modelsimportObjectDB
+
+ value=to_str(value)
+ ifvalue.isdigit()orvalue.startswith("#"):
+ dbid=dbref(value,reqhash=False)
+ ifdbid:
+ try:
+ value=ObjectDB.objects.get(id=dbid)
+ exceptObjectDoesNotExist:
+ # maybe it is just a name that happens to look like a dbid
+ pass
+ ifvalue.__class__.__name__=="AccountDB":
+ fname="db_account"
+ _SA(self,fname,value)
+ else:
+ fname="db_obj"
+ _SA(self,fname,value)
+ # saving the field
+ _GA(self,"save")(update_fields=[fname])
+
+ obj=property(__get_obj,__set_obj)
+ object=property(__get_obj,__set_obj)
+"""
+Monitors - catch changes to model fields and Attributes.
+
+The MONITOR_HANDLER singleton from this module offers the following
+functionality:
+
+- Field-monitor - track a object's specific database field and perform
+ an action whenever that field *changes* for whatever reason.
+- Attribute-monitor tracks an object's specific Attribute and perform
+ an action whenever that Attribute *changes* for whatever reason.
+
+"""
+importinspect
+
+fromcollectionsimportdefaultdict
+fromevennia.server.modelsimportServerConfig
+fromevennia.utils.dbserializeimportdbserialize,dbunserialize
+fromevennia.utilsimportlogger
+fromevennia.utilsimportvariable_from_module
+
+_SA=object.__setattr__
+_GA=object.__getattribute__
+_DA=object.__delattr__
+
+
+
[docs]classMonitorHandler(object):
+ """
+ This is a resource singleton that allows for registering
+ callbacks for when a field or Attribute is updated (saved).
+ """
+
+
[docs]defsave(self):
+ """
+ Store our monitors to the database. This is called
+ by the server process.
+
+ Since dbserialize can't handle defaultdicts, we convert to an
+ intermediary save format ((obj,fieldname, idstring, callback, kwargs), ...)
+
+ """
+ savedata=[]
+ ifself.monitors:
+ forobjinself.monitors:
+ forfieldnameinself.monitors[obj]:
+ foridstring,(callback,persistent,kwargs)inself.monitors[obj][
+ fieldname
+ ].items():
+ path="%s.%s"%(callback.__module__,callback.__name__)
+ savedata.append((obj,fieldname,idstring,path,persistent,kwargs))
+ savedata=dbserialize(savedata)
+ ServerConfig.objects.conf(key=self.savekey,value=savedata)
+
+
[docs]defrestore(self,server_reload=True):
+ """
+ Restore our monitors after a reload. This is called
+ by the server process.
+
+ Args:
+ server_reload (bool, optional): If this is False, it means
+ the server went through a cold reboot and all
+ non-persistent tickers must be killed.
+
+ """
+ self.monitors=defaultdict(lambda:defaultdict(dict))
+ restored_monitors=ServerConfig.objects.conf(key=self.savekey)
+ ifrestored_monitors:
+ restored_monitors=dbunserialize(restored_monitors)
+ for(obj,fieldname,idstring,path,persistent,kwargs)inrestored_monitors:
+ try:
+ ifnotserver_reloadandnotpersistent:
+ # this monitor will not be restarted
+ continue
+ if"session"inkwargsandnotkwargs["session"]:
+ # the session was removed because it no longer
+ # exists. Don't restart the monitor.
+ continue
+ modname,varname=path.rsplit(".",1)
+ callback=variable_from_module(modname,varname)
+
+ ifobjandhasattr(obj,fieldname):
+ self.monitors[obj][fieldname][idstring]=(callback,persistent,kwargs)
+ exceptException:
+ continue
+ # make sure to clean data from database
+ ServerConfig.objects.conf(key=self.savekey,delete=True)
+
+
[docs]defat_update(self,obj,fieldname):
+ """
+ Called by the field/attribute as it saves.
+
+ """
+ to_delete=[]
+ ifobjinself.monitorsandfieldnameinself.monitors[obj]:
+ foridstring,(callback,persistent,kwargs)inself.monitors[obj][fieldname].items():
+ try:
+ callback(obj=obj,fieldname=fieldname,**kwargs)
+ exceptException:
+ to_delete.append((obj,fieldname,idstring))
+ logger.log_trace("Monitor callback was removed.")
+ # we cleanup non-found monitors (has to be done after loop)
+ for(obj,fieldname,idstring)into_delete:
+ delself.monitors[obj][fieldname][idstring]
+
+
[docs]defadd(self,obj,fieldname,callback,idstring="",persistent=False,**kwargs):
+ """
+ Add monitoring to a given field or Attribute. A field must
+ be specified with the full db_* name or it will be assumed
+ to be an Attribute (so `db_key`, not just `key`).
+
+ Args:
+ obj (Typeclassed Entity): The entity on which to monitor a
+ field or Attribute.
+ fieldname (str): Name of field (db_*) or Attribute to monitor.
+ callback (callable): A callable on the form `callable(**kwargs),
+ where kwargs holds keys fieldname and obj.
+ idstring (str, optional): An id to separate this monitor from other monitors
+ of the same field and object.
+ persistent (bool, optional): If False, the monitor will survive
+ a server reload but not a cold restart. This is default.
+
+ Keyword Args:
+ session (Session): If this keyword is given, the monitorhandler will
+ correctly analyze it and remove the monitor if after a reload/reboot
+ the session is no longer valid.
+ any (any): Any other kwargs are passed on to the callback. Remember that
+ all kwargs must be possible to pickle!
+
+ """
+ ifnotfieldname.startswith("db_")ornothasattr(obj,fieldname):
+ # an Attribute - we track its db_value field
+ obj=obj.attributes.get(fieldname,return_obj=True)
+ ifnotobj:
+ return
+ fieldname="db_value"
+
+ # we try to serialize this data to test it's valid. Otherwise we won't accept it.
+ try:
+ ifnotinspect.isfunction(callback):
+ raiseTypeError("callback is not a function.")
+ dbserialize((obj,fieldname,callback,idstring,persistent,kwargs))
+ exceptException:
+ err="Invalid monitor definition: \n"" (%s, %s, %s, %s, %s, %s)"%(
+ obj,
+ fieldname,
+ callback,
+ idstring,
+ persistent,
+ kwargs,
+ )
+ logger.log_trace(err)
+ else:
+ self.monitors[obj][fieldname][idstring]=(callback,persistent,kwargs)
[docs]defall(self,obj=None):
+ """
+ List all monitors or all monitors of a given object.
+
+ Args:
+ obj (Object): The object on which to list all monitors.
+
+ Returns:
+ monitors (list): The handled monitors.
+
+ """
+ output=[]
+ objs=[obj]ifobjelseself.monitors
+
+ forobjinobjs:
+ forfieldnameinself.monitors[obj]:
+ foridstring,(callback,persistent,kwargs)inself.monitors[obj][
+ fieldname
+ ].items():
+ output.append((obj,fieldname,idstring,persistent,kwargs))
+ returnoutput
+"""
+The script handler makes sure to check through all stored scripts to
+make sure they are still relevant. A scripthandler is automatically
+added to all game objects. You access it through the property
+`scripts` on the game object.
+
+"""
+fromevennia.scripts.modelsimportScriptDB
+fromevennia.utilsimportcreate
+fromevennia.utilsimportlogger
+
+fromdjango.utils.translationimportgettextas_
+
+
+
[docs]classScriptHandler(object):
+ """
+ Implements the handler. This sits on each game object.
+
+ """
+
+
[docs]def__init__(self,obj):
+ """
+ Set up internal state.
+
+ Args:
+ obj (Object): A reference to the object this handler is
+ attached to.
+
+ """
+ self.obj=obj
[docs]defadd(self,scriptclass,key=None,autostart=True):
+ """
+ Add a script to this object.
+
+ Args:
+ scriptclass (Scriptclass, Script or str): Either a class
+ object inheriting from DefaultScript, an instantiated
+ script object or a python path to such a class object.
+ key (str, optional): Identifier for the script (often set
+ in script definition and listings)
+ autostart (bool, optional): Start the script upon adding it.
+
+ """
+ ifself.obj.__dbclass__.__name__=="AccountDB":
+ # we add to an Account, not an Object
+ script=create.create_script(
+ scriptclass,key=key,account=self.obj,autostart=autostart
+ )
+ else:
+ # the normal - adding to an Object. We wait to autostart so we can differentiate
+ # a failing creation from a script that immediately starts/stops.
+ script=create.create_script(scriptclass,key=key,obj=self.obj,autostart=False)
+ ifnotscript:
+ logger.log_err("Script %s failed to be created/started."%scriptclass)
+ returnFalse
+ ifautostart:
+ script.start()
+ ifnotscript.id:
+ # this can happen if the script has repeats=1 or calls stop() in at_repeat.
+ logger.log_info(
+ "Script %s started and then immediately stopped; "
+ "it could probably be a normal function."%scriptclass
+ )
+ returnTrue
+
+
[docs]defstart(self,key):
+ """
+ Find scripts and force-start them
+
+ Args:
+ key (str): The script's key or dbref.
+
+ Returns:
+ nr_started (int): The number of started scripts found.
+
+ """
+ scripts=ScriptDB.objects.get_all_scripts_on_obj(self.obj,key=key)
+ num=0
+ forscriptinscripts:
+ num+=script.start()
+ returnnum
+
+
[docs]defget(self,key):
+ """
+ Search scripts on this object.
+
+ Args:
+ key (str): Search criterion, the script's key or dbref.
+
+ Returns:
+ scripts (list): The found scripts matching `key`.
+
+ """
+ returnlist(ScriptDB.objects.get_all_scripts_on_obj(self.obj,key=key))
+
+
[docs]defdelete(self,key=None):
+ """
+ Forcibly delete a script from this object.
+
+ Args:
+ key (str, optional): A script key or the path to a script (in the
+ latter case all scripts with this path will be deleted!)
+ If no key is given, delete *all* scripts on the object!
+
+ """
+ delscripts=ScriptDB.objects.get_all_scripts_on_obj(self.obj,key=key)
+ ifnotdelscripts:
+ delscripts=[
+ script
+ forscriptinScriptDB.objects.get_all_scripts_on_obj(self.obj)
+ ifscript.path==key
+ ]
+ num=0
+ forscriptindelscripts:
+ num+=script.stop()
+ returnnum
+
+ # alias to delete
+ stop=delete
+
+
[docs]defall(self):
+ """
+ Get all scripts stored in this handler.
+
+ """
+ returnScriptDB.objects.get_all_scripts_on_obj(self.obj)
+
+
[docs]defvalidate(self,init_mode=False):
+ """
+ Runs a validation on this object's scripts only. This should
+ be called regularly to crank the wheels.
+
+ Args:
+ init_mode (str, optional): - This is used during server
+ upstart and can have three values:
+ - `False` (no init mode). Called during run.
+ - `"reset"` - server reboot. Kill non-persistent scripts
+ - `"reload"` - server reload. Keep non-persistent scripts.
+
+ """
+ ScriptDB.objects.validate(obj=self.obj,init_mode=init_mode)
+"""
+This module defines Scripts, out-of-character entities that can store
+data both on themselves and on other objects while also having the
+ability to run timers.
+
+"""
+
+fromtwisted.internet.deferimportDeferred,maybeDeferred
+fromtwisted.internet.taskimportLoopingCall
+fromdjango.core.exceptionsimportObjectDoesNotExist
+fromdjango.utils.translationimportgettextas_
+fromevennia.typeclasses.modelsimportTypeclassBase
+fromevennia.scripts.modelsimportScriptDB
+fromevennia.scripts.managerimportScriptManager
+fromevennia.utilsimportcreate,logger
+
+__all__=["DefaultScript","DoNothing","Store"]
+
+
+FLUSHING_INSTANCES=False# whether we're in the process of flushing scripts from the cache
+SCRIPT_FLUSH_TIMERS={}# stores timers for scripts that are currently being flushed
+
+
+defrestart_scripts_after_flush():
+ """After instances are flushed, validate scripts so they're not dead for a long period of time"""
+ globalFLUSHING_INSTANCES
+ ScriptDB.objects.validate()
+ FLUSHING_INSTANCES=False
+
+
+classExtendedLoopingCall(LoopingCall):
+ """
+ LoopingCall that can start at a delay different
+ than `self.interval`.
+
+ """
+
+ start_delay=None
+ callcount=0
+
+ defstart(self,interval,now=True,start_delay=None,count_start=0):
+ """
+ Start running function every interval seconds.
+
+ This overloads the LoopingCall default by offering the
+ start_delay keyword and ability to repeat.
+
+ Args:
+ interval (int): Repeat interval in seconds.
+ now (bool, optional): Whether to start immediately or after
+ `start_delay` seconds.
+ start_delay (int): The number of seconds before starting.
+ If None, wait interval seconds. Only valid if `now` is `False`.
+ It is used as a way to start with a variable start time
+ after a pause.
+ count_start (int): Number of repeats to start at. The count
+ goes up every time the system repeats. This is used to
+ implement something repeating `N` number of times etc.
+
+ Raises:
+ AssertError: if trying to start a task which is already running.
+ ValueError: If interval is set to an invalid value < 0.
+
+ Notes:
+ As opposed to Twisted's inbuilt count mechanism, this
+ system will count also if force_repeat() was called rather
+ than just the number of `interval` seconds since the start.
+ This allows us to force-step through a limited number of
+ steps if we want.
+
+ """
+ assertnotself.running,"Tried to start an already running ExtendedLoopingCall."
+ ifinterval<0:
+ raiseValueError("interval must be >= 0")
+ self.running=True
+ deferred=self._deferred=Deferred()
+ self.starttime=self.clock.seconds()
+ self.interval=interval
+ self._runAtStart=now
+ self.callcount=max(0,count_start)
+ self.start_delay=start_delayifstart_delayisNoneelsemax(0,start_delay)
+
+ ifnow:
+ # run immediately
+ self()
+ elifstart_delayisnotNoneandstart_delay>=0:
+ # start after some time: for this to work we need to
+ # trick _scheduleFrom by temporarily setting a different
+ # self.interval for it to check.
+ real_interval,self.interval=self.interval,start_delay
+ self._scheduleFrom(self.starttime)
+ # re-set the actual interval (this will be picked up
+ # next time it runs
+ self.interval=real_interval
+ else:
+ self._scheduleFrom(self.starttime)
+ returndeferred
+
+ def__call__(self):
+ """
+ Tick one step. We update callcount (tracks number of calls) as
+ well as null start_delay (needed in order to correctly
+ estimate next_call_time at all times).
+
+ """
+ self.callcount+=1
+ ifself.start_delay:
+ self.start_delay=None
+ self.starttime=self.clock.seconds()
+ ifself._deferred:
+ LoopingCall.__call__(self)
+
+ defforce_repeat(self):
+ """
+ Force-fire the callback
+
+ Raises:
+ AssertionError: When trying to force a task that is not
+ running.
+
+ """
+ assertself.running,"Tried to fire an ExtendedLoopingCall that was not running."
+ self.call.cancel()
+ self.call=None
+ self.starttime=self.clock.seconds()
+ self()
+
+ defnext_call_time(self):
+ """
+ Get the next call time. This also takes the eventual effect
+ of start_delay into account.
+
+ Returns:
+ next (int or None): The time in seconds until the next call. This
+ takes `start_delay` into account. Returns `None` if
+ the task is not running.
+
+ """
+ ifself.runningandself.interval>0:
+ total_runtime=self.clock.seconds()-self.starttime
+ interval=self.start_delayorself.interval
+ returninterval-(total_runtime%self.interval)
+
+
+classScriptBase(ScriptDB,metaclass=TypeclassBase):
+ """
+ Base class for scripts. Don't inherit from this, inherit from the
+ class `DefaultScript` below instead.
+
+ """
+
+ objects=ScriptManager()
+
+ def__str__(self):
+ return"<{cls}{key}>".format(cls=self.__class__.__name__,key=self.key)
+
+ def__repr__(self):
+ returnstr(self)
+
+ def_start_task(self):
+ """
+ Start task runner.
+
+ """
+ ifnotself.ndb._task:
+ self.ndb._task=ExtendedLoopingCall(self._step_task)
+
+ ifself.db._paused_time:
+ # the script was paused; restarting
+ callcount=self.db._paused_callcountor0
+ self.ndb._task.start(
+ self.db_interval,now=False,start_delay=self.db._paused_time,count_start=callcount
+ )
+ delself.db._paused_time
+ delself.db._paused_repeats
+
+ elifnotself.ndb._task.running:
+ # starting script anew
+ self.ndb._task.start(self.db_interval,now=notself.db_start_delay)
+
+ def_stop_task(self):
+ """
+ Stop task runner
+
+ """
+ task=self.ndb._task
+ iftaskandtask.running:
+ task.stop()
+ self.ndb._task=None
+
+ def_step_errback(self,e):
+ """
+ Callback for runner errors
+
+ """
+ cname=self.__class__.__name__
+ estring=_(
+ "Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'."
+ )%{"key":self.key,"dbid":self.dbid,"cname":cname,"err":e.getErrorMessage()}
+ try:
+ self.db_obj.msg(estring)
+ exceptException:
+ # we must not crash inside the errback, even if db_obj is None.
+ pass
+ logger.log_err(estring)
+
+ def_step_callback(self):
+ """
+ Step task runner. No try..except needed due to defer wrap.
+
+ """
+ ifnotself.ndb._task:
+ # if there is no task, we have no business using this method
+ return
+
+ ifnotself.is_valid():
+ self.stop()
+ return
+
+ # call hook
+ self.at_repeat()
+
+ # check repeats
+ ifself.ndb._task:
+ # we need to check for the task in case stop() was called
+ # inside at_repeat() and it already went away.
+ callcount=self.ndb._task.callcount
+ maxcount=self.db_repeats
+ ifmaxcount>0andmaxcount<=callcount:
+ self.stop()
+
+ def_step_task(self):
+ """
+ Step task. This groups error handling.
+ """
+ try:
+ returnmaybeDeferred(self._step_callback).addErrback(self._step_errback)
+ exceptException:
+ logger.log_trace()
+ returnNone
+
+ defat_script_creation(self):
+ """
+ Should be overridden in child.
+
+ """
+ pass
+
+ defat_first_save(self,**kwargs):
+ """
+ This is called after very first time this object is saved.
+ Generally, you don't need to overload this, but only the hooks
+ called by this method.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ self.at_script_creation()
+
+ ifhasattr(self,"_createdict"):
+ # this will only be set if the utils.create_script
+ # function was used to create the object. We want
+ # the create call's kwargs to override the values
+ # set by hooks.
+ cdict=self._createdict
+ updates=[]
+ ifnotcdict.get("key"):
+ ifnotself.db_key:
+ self.db_key="#%i"%self.dbid
+ updates.append("db_key")
+ elifself.db_key!=cdict["key"]:
+ self.db_key=cdict["key"]
+ updates.append("db_key")
+ ifcdict.get("interval")andself.interval!=cdict["interval"]:
+ self.db_interval=max(0,cdict["interval"])
+ updates.append("db_interval")
+ ifcdict.get("start_delay")andself.start_delay!=cdict["start_delay"]:
+ self.db_start_delay=cdict["start_delay"]
+ updates.append("db_start_delay")
+ ifcdict.get("repeats")andself.repeats!=cdict["repeats"]:
+ self.db_repeats=max(0,cdict["repeats"])
+ updates.append("db_repeats")
+ ifcdict.get("persistent")andself.persistent!=cdict["persistent"]:
+ self.db_persistent=cdict["persistent"]
+ updates.append("db_persistent")
+ ifcdict.get("desc")andself.desc!=cdict["desc"]:
+ self.db_desc=cdict["desc"]
+ updates.append("db_desc")
+ ifupdates:
+ self.save(update_fields=updates)
+
+ ifcdict.get("permissions"):
+ self.permissions.batch_add(*cdict["permissions"])
+ ifcdict.get("locks"):
+ self.locks.add(cdict["locks"])
+ ifcdict.get("tags"):
+ # this should be a list of tags, tuples (key, category) or (key, category, data)
+ self.tags.batch_add(*cdict["tags"])
+ ifcdict.get("attributes"):
+ # this should be tuples (key, val, ...)
+ self.attributes.batch_add(*cdict["attributes"])
+ ifcdict.get("nattributes"):
+ # this should be a dict of nattrname:value
+ forkey,valueincdict["nattributes"]:
+ self.nattributes.add(key,value)
+
+ ifnotcdict.get("autostart"):
+ # don't auto-start the script
+ return
+
+ # auto-start script (default)
+ self.start()
+
+
+
[docs]classDefaultScript(ScriptBase):
+ """
+ This is the base TypeClass for all Scripts. Scripts describe
+ events, timers and states in game, they can have a time component
+ or describe a state that changes under certain conditions.
+
+ """
+
+
[docs]@classmethod
+ defcreate(cls,key,**kwargs):
+ """
+ Provides a passthrough interface to the utils.create_script() function.
+
+ Args:
+ key (str): Name of the new object.
+
+ Returns:
+ object (Object): A newly created object of the given typeclass.
+ errors (list): A list of errors in string form, if any.
+
+ """
+ errors=[]
+ obj=None
+
+ kwargs["key"]=key
+
+ # If no typeclass supplied, use this class
+ kwargs["typeclass"]=kwargs.pop("typeclass",cls)
+
+ try:
+ obj=create.create_script(**kwargs)
+ exceptException:
+ logger.log_trace()
+ errors.append("The script '%s' encountered errors and could not be created."%key)
+
+ returnobj,errors
+
+
[docs]defat_script_creation(self):
+ """
+ Only called once, when script is first created.
+
+ """
+ pass
+
+
[docs]deftime_until_next_repeat(self):
+ """
+ Get time until the script fires it `at_repeat` hook again.
+
+ Returns:
+ next (int): Time in seconds until the script runs again.
+ If not a timed script, return `None`.
+
+ Notes:
+ This hook is not used in any way by the script's stepping
+ system; it's only here for the user to be able to check in
+ on their scripts and when they will next be run.
+
+ """
+ task=self.ndb._task
+ iftask:
+ try:
+ returnint(round(task.next_call_time()))
+ exceptTypeError:
+ pass
+ returnNone
+
+
[docs]defremaining_repeats(self):
+ """
+ Get the number of returning repeats for limited Scripts.
+
+ Returns:
+ remaining (int or `None`): The number of repeats
+ remaining until the Script stops. Returns `None`
+ if it has unlimited repeats.
+
+ """
+ task=self.ndb._task
+ iftask:
+ returnmax(0,self.db_repeats-task.callcount)
+ returnNone
+
+
[docs]defat_idmapper_flush(self):
+ """If we're flushing this object, make sure the LoopingCall is gone too"""
+ ret=super(DefaultScript,self).at_idmapper_flush()
+ ifretandself.ndb._task:
+ try:
+ fromtwisted.internetimportreactor
+
+ globalFLUSHING_INSTANCES
+ # store the current timers for the _task and stop it to avoid duplicates after cache flush
+ paused_time=self.ndb._task.next_call_time()
+ callcount=self.ndb._task.callcount
+ self._stop_task()
+ SCRIPT_FLUSH_TIMERS[self.id]=(paused_time,callcount)
+ # here we ensure that the restart call only happens once, not once per script
+ ifnotFLUSHING_INSTANCES:
+ FLUSHING_INSTANCES=True
+ reactor.callLater(2,restart_scripts_after_flush)
+ exceptException:
+ importtraceback
+
+ traceback.print_exc()
+ returnret
+
+
[docs]defstart(self,force_restart=False):
+ """
+ Called every time the script is started (for persistent
+ scripts, this is usually once every server start)
+
+ Args:
+ force_restart (bool, optional): Normally an already
+ started script will not be started again. if
+ `force_restart=True`, the script will always restart
+ the script, regardless of if it has started before.
+
+ Returns:
+ result (int): 0 or 1 depending on if the script successfully
+ started or not. Used in counting.
+
+ """
+ ifself.is_activeandnotforce_restart:
+ # The script is already running, but make sure we have a _task if
+ # this is after a cache flush
+ ifnotself.ndb._taskandself.db_interval>0:
+ self.ndb._task=ExtendedLoopingCall(self._step_task)
+ try:
+ start_delay,callcount=SCRIPT_FLUSH_TIMERS[self.id]
+ delSCRIPT_FLUSH_TIMERS[self.id]
+ now=False
+ except(KeyError,ValueError,TypeError):
+ now=notself.db_start_delay
+ start_delay=None
+ callcount=0
+ self.ndb._task.start(
+ self.db_interval,now=now,start_delay=start_delay,count_start=callcount
+ )
+ return0
+
+ obj=self.obj
+ ifobj:
+ # check so the scripted object is valid and initalized
+ try:
+ obj.cmdset
+ exceptAttributeError:
+ # this means the object is not initialized.
+ logger.log_trace()
+ self.is_active=False
+ return0
+
+ # try to restart a paused script
+ try:
+ ifself.unpause(manual_unpause=False):
+ return1
+ exceptRuntimeError:
+ # manually paused.
+ return0
+
+ # start the script from scratch
+ self.is_active=True
+ try:
+ self.at_start()
+ exceptException:
+ logger.log_trace()
+
+ ifself.db_interval>0:
+ self._start_task()
+ return1
+
+
[docs]defstop(self,kill=False):
+ """
+ Called to stop the script from running. This also deletes the
+ script.
+
+ Args:
+ kill (bool, optional): - Stop the script without
+ calling any relevant script hooks.
+
+ Returns:
+ result (int): 0 if the script failed to stop, 1 otherwise.
+ Used in counting.
+
+ """
+ ifnotkill:
+ try:
+ self.at_stop()
+ exceptException:
+ logger.log_trace()
+ self._stop_task()
+ try:
+ self.delete()
+ exceptAssertionError:
+ logger.log_trace()
+ return0
+ exceptObjectDoesNotExist:
+ return0
+ return1
+
+
[docs]defpause(self,manual_pause=True):
+ """
+ This stops a running script and stores its active state.
+ It WILL NOT call the `at_stop()` hook.
+
+ """
+ self.db._manual_pause=manual_pause
+ ifnotself.db._paused_time:
+ # only allow pause if not already paused
+ task=self.ndb._task
+ iftask:
+ self.db._paused_time=task.next_call_time()
+ self.db._paused_callcount=task.callcount
+ self._stop_task()
+ self.is_active=False
+
+
[docs]defunpause(self,manual_unpause=True):
+ """
+ Restart a paused script. This WILL call the `at_start()` hook.
+
+ Args:
+ manual_unpause (bool, optional): This is False if unpause is
+ called by the server reload/reset mechanism.
+ Returns:
+ result (bool): True if unpause was triggered, False otherwise.
+
+ Raises:
+ RuntimeError: If trying to automatically resart this script
+ (usually after a reset/reload), but it was manually paused,
+ and so should not the auto-unpaused.
+
+ """
+ ifnotmanual_unpauseandself.db._manual_pause:
+ # if this script was paused manually (by a direct call of pause),
+ # it cannot be automatically unpaused (e.g. by a @reload)
+ raiseRuntimeError
+
+ # Ensure that the script is fully unpaused, so that future calls
+ # to unpause do not raise a RuntimeError
+ self.db._manual_pause=False
+
+ ifself.db._paused_time:
+ # only unpause if previously paused
+ self.is_active=True
+
+ try:
+ self.at_start()
+ exceptException:
+ logger.log_trace()
+
+ self._start_task()
+ returnTrue
+
+
[docs]defrestart(self,interval=None,repeats=None,start_delay=None):
+ """
+ Restarts an already existing/running Script from the
+ beginning, optionally using different settings. This will
+ first call the stop hooks, and then the start hooks again.
+ Args:
+ interval (int, optional): Allows for changing the interval
+ of the Script. Given in seconds. if `None`, will use the already stored interval.
+ repeats (int, optional): The number of repeats. If unset, will
+ use the previous setting.
+ start_delay (bool, optional): If we should wait `interval` seconds
+ before starting or not. If `None`, re-use the previous setting.
+
+ """
+ try:
+ self.at_stop()
+ exceptException:
+ logger.log_trace()
+ self._stop_task()
+ self.is_active=False
+ # remove all pause flags
+ delself.db._paused_time
+ delself.db._manual_pause
+ delself.db._paused_callcount
+ # set new flags and start over
+ ifintervalisnotNone:
+ interval=max(0,interval)
+ self.interval=interval
+ ifrepeatsisnotNone:
+ self.repeats=repeats
+ ifstart_delayisnotNone:
+ self.start_delay=start_delay
+ self.start()
+
+
[docs]defreset_callcount(self,value=0):
+ """
+ Reset the count of the number of calls done.
+
+ Args:
+ value (int, optional): The repeat value to reset to. Default
+ is to set it all the way back to 0.
+
+ Notes:
+ This is only useful if repeats != 0.
+
+ """
+ task=self.ndb._task
+ iftask:
+ task.callcount=max(0,int(value))
+
+
[docs]defforce_repeat(self):
+ """
+ Fire a premature triggering of the script callback. This
+ will reset the timer and count down repeats as if the script
+ had fired normally.
+ """
+ task=self.ndb._task
+ iftask:
+ task.force_repeat()
+
+
[docs]defis_valid(self):
+ """
+ Is called to check if the script is valid to run at this time.
+ Should return a boolean. The method is assumed to collect all
+ needed information from its related self.obj.
+
+ """
+ returnnotself._is_deleted
+
+
[docs]defat_start(self,**kwargs):
+ """
+ Called whenever the script is started, which for persistent
+ scripts is at least once every server start. It will also be
+ called when starting again after a pause (such as after a
+ server reload)
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_repeat(self,**kwargs):
+ """
+ Called repeatedly if this Script is set to repeat regularly.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_stop(self,**kwargs):
+ """
+ Called whenever when it's time for this script to stop (either
+ because is_valid returned False or it runs out of iterations)
+
+ Args
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
+
[docs]defat_server_reload(self):
+ """
+ This hook is called whenever the server is shutting down for
+ restart/reboot. If you want to, for example, save
+ non-persistent properties across a restart, this is the place
+ to do it.
+ """
+ pass
+
+
[docs]defat_server_shutdown(self):
+ """
+ This hook is called whenever the server is shutting down fully
+ (i.e. not for a restart).
+ """
+ pass
+
+
+# Some useful default Script types used by Evennia.
+
+
+
[docs]classDoNothing(DefaultScript):
+ """
+ A script that does nothing. Used as default fallback.
+ """
+
+
[docs]defat_script_creation(self):
+ """
+ Setup the script
+ """
+ self.key="sys_do_nothing"
+ self.desc="This is an empty placeholder script."
[docs]classTaskHandlerTask:
+ """An object to represent a single TaskHandler task.
+
+ Instance Attributes:
+ task_id (int): the global id for this task
+ deferred (deferred): a reference to this task's deferred
+ Property Attributes:
+ paused (bool): check if the deferred instance of a task has been paused.
+ called(self): A task attribute to check if the deferred instance of a task has been called.
+
+ Methods:
+ pause(): Pause the callback of a task.
+ unpause(): Process all callbacks made since pause() was called.
+ do_task(): Execute the task (call its callback).
+ call(): Call the callback of this task.
+ remove(): Remove a task without executing it.
+ cancel(): Stop a task from automatically executing.
+ active(): Check if a task is active (has not been called yet).
+ exists(): Check if a task exists.
+ get_id(): Returns the global id for this task. For use with
+
+ """
+
+
[docs]defget_deferred(self):
+ """Return the instance of the deferred the task id is using.
+
+ Returns:
+ bool or deferred: An instance of a deferred or False if there is no task with the id.
+ None is returned if there is no deferred affiliated with this id.
+
+ """
+ returnTASK_HANDLER.get_deferred(self.task_id)
+
+
[docs]defpause(self):
+ """Pause the callback of a task.
+ To resume use TaskHandlerTask.unpause
+ """
+ d=self.deferred
+ ifd:
+ d.pause()
+
+
[docs]defunpause(self):
+ """Unpause a task, run the task if it has passed delay time."""
+ d=self.deferred
+ ifd:
+ d.unpause()
+
+ @property
+ defpaused(self):
+ """A task attribute to check if the deferred instance of a task has been paused.
+
+ This exists to mock usage of a twisted deferred object.
+
+ Returns:
+ bool or None: True if the task was properly paused. None if the task does not have
+ a deferred instance.
+
+ """
+ d=self.deferred
+ ifd:
+ returnd.paused
+ else:
+ returnNone
+
+
[docs]defdo_task(self):
+ """Execute the task (call its callback).
+ If calling before timedelay, cancel the deferred instance affliated to this task.
+ Remove the task from the dictionary of current tasks on a successful
+ callback.
+
+ Returns:
+ bool or any: Set to `False` if the task does not exist in task
+ handler. Otherwise it will be the return of the task's callback.
+
+ """
+ returnTASK_HANDLER.do_task(self.task_id)
+
+
[docs]defcall(self):
+ """Call the callback of a task.
+ Leave the task unaffected otherwise.
+ This does not use the task's deferred instance.
+ The only requirement is that the task exist in task handler.
+
+ Returns:
+ bool or any: Set to `False` if the task does not exist in task
+ handler. Otherwise it will be the return of the task's callback.
+
+ """
+ returnTASK_HANDLER.call_task(self.task_id)
+
+
[docs]defremove(self):
+ """Remove a task without executing it.
+ Deletes the instance of the task's deferred.
+
+ Args:
+ task_id (int): an existing task ID.
+
+ Returns:
+ bool: True if the removal completed successfully.
+
+ """
+ returnTASK_HANDLER.remove(self.task_id)
+
+
[docs]defcancel(self):
+ """Stop a task from automatically executing.
+ This will not remove the task.
+
+ Returns:
+ bool: True if the cancel completed successfully.
+ False if the cancel did not complete successfully.
+
+ """
+ returnTASK_HANDLER.cancel(self.task_id)
+
+
[docs]defactive(self):
+ """Check if a task is active (has not been called yet).
+
+ Returns:
+ bool: True if a task is active (has not been called yet). False if
+ it is not (has been called) or if the task does not exist.
+
+ """
+ returnTASK_HANDLER.active(self.task_id)
+
+ @property
+ defcalled(self):
+ """
+ A task attribute to check if the deferred instance of a task has been called.
+
+ This exists to mock usage of a twisted deferred object.
+ It will not set to True if Task.call has been called. This only happens if
+ task's deferred instance calls the callback.
+
+ Returns:
+ bool: True if the deferred instance of this task has called the callback.
+ False if the deferred instnace of this task has not called the callback.
+
+ """
+ d=self.deferred
+ ifd:
+ returnd.called
+ else:
+ returnNone
+
+
[docs]defexists(self):
+ """Check if a task exists.
+ Most task handler methods check for existence for you.
+
+ Returns:
+ bool: True the task exists False if it does not.
+
+ """
+ returnTASK_HANDLER.exists(self.task_id)
+
+
[docs]defget_id(self):
+ """ Returns the global id for this task. For use with
+ `evennia.scripts.taskhandler.TASK_HANDLER`.
+
+ Returns:
+ task_id (int): global task id for this task.
+
+ """
+ returnself.task_id
+
+
+
[docs]classTaskHandler(object):
+
+ """A light singleton wrapper allowing to access permanent tasks.
+
+ When `utils.delay` is called, the task handler is used to create
+ the task.
+
+ Task handler will automatically remove uncalled but canceled from task
+ handler. By default this will not occur until a canceled task
+ has been uncalled for 60 second after the time it should have been called.
+ To adjust this time use TASK_HANDLER.stale_timeout. If stale_timeout is 0
+ stale tasks will not be automatically removed.
+ This is not done on a timer. I is done as new tasks are added or the load method is called.
+
+ """
+
+
[docs]def__init__(self):
+ self.tasks={}
+ self.to_save={}
+ self.clock=reactor
+ # number of seconds before an uncalled canceled task is removed from TaskHandler
+ self.stale_timeout=60
+ self._now=False# used in unit testing to manually set now time
+
+
[docs]defload(self):
+ """Load from the ServerConfig.
+
+ This should be automatically called when Evennia starts.
+ It populates `self.tasks` according to the ServerConfig.
+
+ """
+ to_save=False
+ value=ServerConfig.objects.conf("delayed_tasks",default={})
+ ifisinstance(value,str):
+ tasks=dbunserialize(value)
+ else:
+ tasks=value
+
+ # At this point, `tasks` contains a dictionary of still-serialized tasks
+ fortask_id,valueintasks.items():
+ date,callback,args,kwargs=dbunserialize(value)
+ ifisinstance(callback,tuple):
+ # `callback` can be an object and name for instance methods
+ obj,method=callback
+ ifobjisNone:
+ to_save=True
+ continue
+
+ callback=getattr(obj,method)
+ self.tasks[task_id]=(date,callback,args,kwargs,True,None)
+
+ ifself.stale_timeout>0:# cleanup stale tasks.
+ self.clean_stale_tasks()
+ ifto_save:
+ self.save()
+
+
[docs]defclean_stale_tasks(self):
+ """remove uncalled but canceled from task handler.
+
+ By default this will not occur until a canceled task
+ has been uncalled for 60 second after the time it should have been called.
+ To adjust this time use TASK_HANDLER.stale_timeout.
+
+ """
+ clean_ids=[]
+ fortask_id,(date,callback,args,kwargs,persistent,_)inself.tasks.items():
+ ifnotself.active(task_id):
+ stale_date=date+timedelta(seconds=self.stale_timeout)
+ # if a now time is provided use it (intended for unit testing)
+ now=self._nowifself._nowelsedatetime.now()
+ # the task was canceled more than stale_timeout seconds ago
+ ifnow>stale_date:
+ clean_ids.append(task_id)
+ fortask_idinclean_ids:
+ self.remove(task_id)
+ returnTrue
+
+
[docs]defsave(self):
+ """Save the tasks in ServerConfig."""
+
+ fortask_id,(date,callback,args,kwargs,persistent,_)inself.tasks.items():
+ iftask_idinself.to_save:
+ continue
+ ifnotpersistent:
+ continue
+
+ safe_callback=callback
+ ifgetattr(callback,"__self__",None):
+ # `callback` is an instance method
+ obj=callback.__self__
+ name=callback.__name__
+ safe_callback=(obj,name)
+
+ # Check if callback can be pickled. args and kwargs have been checked
+ try:
+ dbserialize(safe_callback)
+ except(TypeError,AttributeError,PickleError)aserr:
+ raiseValueError(
+ "the specified callback {callback} cannot be pickled. "
+ "It must be a top-level function in a module or an "
+ "instance method ({err}).".format(callback=callback,err=err)
+ )
+
+ self.to_save[task_id]=dbserialize((date,safe_callback,args,kwargs))
+ ServerConfig.objects.conf("delayed_tasks",self.to_save)
+
+
[docs]defadd(self,timedelay,callback,*args,**kwargs):
+ """Add a new task.
+
+ If the persistent kwarg is truthy:
+ The callback, args and values for kwarg will be serialized. Type
+ and attribute errors during the serialization will be logged,
+ but will not throw exceptions.
+ For persistent tasks do not use memory references in the callback
+ function or arguments. After a restart those memory references are no
+ longer accurate.
+
+ Args:
+ timedelay (int or float): time in seconds before calling the callback.
+ callback (function or instance method): the callback itself
+ any (any): any additional positional arguments to send to the callback
+ *args: positional arguments to pass to callback.
+ **kwargs: keyword arguments to pass to callback.
+ - persistent (bool, optional): persist the task (stores it).
+ Persistent key and value is removed from kwargs it will
+ not be passed to callback.
+
+ Returns:
+ TaskHandlerTask: An object to represent a task.
+ Reference evennia.scripts.taskhandler.TaskHandlerTask for complete details.
+
+ """
+ # set the completion time
+ # Only used on persistent tasks after a restart
+ now=datetime.now()
+ delta=timedelta(seconds=timedelay)
+ comp_time=now+delta
+ # get an open task id
+ used_ids=list(self.tasks.keys())
+ task_id=1
+ whiletask_idinused_ids:
+ task_id+=1
+
+ # record the task to the tasks dictionary
+ persistent=kwargs.get("persistent",False)
+ if"persistent"inkwargs:
+ delkwargs["persistent"]
+ ifpersistent:
+ safe_args=[]
+ safe_kwargs={}
+
+ # Check that args and kwargs contain picklable information
+ forarginargs:
+ try:
+ dbserialize(arg)
+ except(TypeError,AttributeError,PickleError):
+ log_err(
+ "The positional argument {} cannot be "
+ "pickled and will not be present in the arguments "
+ "fed to the callback {}".format(arg,callback)
+ )
+ else:
+ safe_args.append(arg)
+
+ forkey,valueinkwargs.items():
+ try:
+ dbserialize(value)
+ except(TypeError,AttributeError,PickleError):
+ log_err(
+ "The {} keyword argument {} cannot be "
+ "pickled and will not be present in the arguments "
+ "fed to the callback {}".format(key,value,callback)
+ )
+ else:
+ safe_kwargs[key]=value
+
+ self.tasks[task_id]=(comp_time,callback,safe_args,safe_kwargs,persistent,None)
+ self.save()
+ else:# this is a non-persitent task
+ self.tasks[task_id]=(comp_time,callback,args,kwargs,persistent,None)
+
+ # defer the task
+ callback=self.do_task
+ args=[task_id]
+ kwargs={}
+ d=deferLater(self.clock,timedelay,callback,*args,**kwargs)
+ d.addErrback(handle_error)
+
+ # some tasks may complete before the deferred can be added
+ iftask_idinself.tasks:
+ task=self.tasks.get(task_id)
+ task=list(task)
+ task[4]=persistent
+ task[5]=d
+ self.tasks[task_id]=task
+ else:# the task already completed
+ returnFalse
+ ifself.stale_timeout>0:
+ self.clean_stale_tasks()
+ returnTaskHandlerTask(task_id)
+
+
[docs]defexists(self,task_id):
+ """Check if a task exists.
+ Most task handler methods check for existence for you.
+
+ Args:
+ task_id (int): an existing task ID.
+
+ Returns:
+ bool: True the task exists False if it does not.
+
+ """
+ iftask_idinself.tasks:
+ returnTrue
+ else:
+ returnFalse
+
+
[docs]defactive(self,task_id):
+ """Check if a task is active (has not been called yet).
+
+ Args:
+ task_id (int): an existing task ID.
+
+ Returns:
+ bool: True if a task is active (has not been called yet). False if
+ it is not (has been called) or if the task does not exist.
+
+ """
+ iftask_idinself.tasks:
+ # if the task has not been run, cancel it
+ deferred=self.get_deferred(task_id)
+ returnnot(deferredanddeferred.called)
+ else:
+ returnFalse
+
+
[docs]defcancel(self,task_id):
+ """Stop a task from automatically executing.
+ This will not remove the task.
+
+ Args:
+ task_id (int): an existing task ID.
+
+ Returns:
+ bool: True if the cancel completed successfully.
+ False if the cancel did not complete successfully.
+
+ """
+ iftask_idinself.tasks:
+ # if the task has not been run, cancel it
+ d=self.get_deferred(task_id)
+ ifd:# it is remotely possible for a task to not have a deferred
+ ifd.called:
+ returnFalse
+ else:# the callback has not been called yet.
+ d.cancel()
+ returnTrue
+ else:# this task has no deferred instance
+ returnFalse
+ else:
+ returnFalse
+
+
[docs]defremove(self,task_id):
+ """Remove a task without executing it.
+ Deletes the instance of the task's deferred.
+
+ Args:
+ task_id (int): an existing task ID.
+
+ Returns:
+ bool: True if the removal completed successfully.
+
+ """
+ d=None
+ # delete the task from the tasks dictionary
+ iftask_idinself.tasks:
+ # if the task has not been run, cancel it
+ self.cancel(task_id)
+ delself.tasks[task_id]# delete the task from the tasks dictionary
+ # remove the task from the persistent dictionary and ServerConfig
+ iftask_idinself.to_save:
+ delself.to_save[task_id]
+ self.save()# remove from ServerConfig.objects
+ # delete the instance of the deferred
+ ifd:
+ deld
+ returnTrue
+
+
[docs]defclear(self,save=True,cancel=True):
+ """clear all tasks.
+ By default tasks are canceled and removed from the database also.
+
+ Args:
+ save=True (bool): Should changes to persistent tasks be saved to database.
+ cancel=True (bool): Cancel scheduled tasks before removing it from task handler.
+
+ Returns:
+ True (bool): if the removal completed successfully.
+
+ """
+ ifself.tasks:
+ fortask_idinself.tasks.keys():
+ ifcancel:
+ self.cancel(task_id)
+ self.tasks={}
+ ifself.to_save:
+ self.to_save={}
+ ifsave:
+ self.save()
+ returnTrue
+
+
[docs]defcall_task(self,task_id):
+ """Call the callback of a task.
+ Leave the task unaffected otherwise.
+ This does not use the task's deferred instance.
+ The only requirement is that the task exist in task handler.
+
+ Args:
+ task_id (int): an existing task ID.
+
+ Returns:
+ bool or any: Set to `False` if the task does not exist in task
+ handler. Otherwise it will be the return of the task's callback.
+
+ """
+ iftask_idinself.tasks:
+ date,callback,args,kwargs,persistent,d=self.tasks.get(task_id)
+ else:# the task does not exist
+ returnFalse
+ returncallback(*args,**kwargs)
+
+
[docs]defdo_task(self,task_id):
+ """Execute the task (call its callback).
+ If calling before timedelay cancel the deferred instance affliated to this task.
+ Remove the task from the dictionary of current tasks on a successful
+ callback.
+
+ Args:
+ task_id (int): a valid task ID.
+
+ Returns:
+ bool or any: Set to `False` if the task does not exist in task
+ handler. Otherwise it will be the return of the task's callback.
+
+ """
+ callback_return=False
+ iftask_idinself.tasks:
+ date,callback,args,kwargs,persistent,d=self.tasks.get(task_id)
+ else:# the task does not exist
+ returnFalse
+ ifd:# it is remotely possible for a task to not have a deferred
+ ifnotd.called:# the task's deferred has not been called yet
+ d.cancel()# cancel the automated callback
+ else:# this task has no deferred, and should not be called
+ returnFalse
+ callback_return=callback(*args,**kwargs)
+ self.remove(task_id)
+ returncallback_return
+
+
[docs]defget_deferred(self,task_id):
+ """
+ Return the instance of the deferred the task id is using.
+
+ Args:
+ task_id (int): a valid task ID.
+
+ Returns:
+ bool or deferred: An instance of a deferred or False if there is no task with the id.
+ None is returned if there is no deferred affiliated with this id.
+
+ """
+ iftask_idinself.tasks:
+ returnself.tasks[task_id][5]
+ else:
+ returnNone
+
+
[docs]defcreate_delays(self):
+ """Create the delayed tasks for the persistent tasks.
+ This method should be automatically called when Evennia starts.
+
+ """
+ now=datetime.now()
+ fortask_id,(date,callback,args,kwargs,_,_)inself.tasks.items():
+ self.tasks[task_id]=date,callback,args,kwargs,True,None
+ seconds=max(0,(date-now).total_seconds())
+ d=deferLater(self.clock,seconds,self.do_task,task_id)
+ d.addErrback(handle_error)
+ # some tasks may complete before the deferred can be added
+ ifself.tasks.get(task_id,False):
+ self.tasks[task_id]=date,callback,args,kwargs,True,d
+
+
+# Create the soft singleton
+TASK_HANDLER=TaskHandler()
+
+"""
+TickerHandler
+
+This implements an efficient Ticker which uses a subscription
+model to 'tick' subscribed objects at regular intervals.
+
+The ticker mechanism is used by importing and accessing
+the instantiated TICKER_HANDLER instance in this module. This
+instance is run by the server; it will save its status across
+server reloads and be started automaticall on boot.
+
+Example:
+
+```python
+ from evennia.scripts.tickerhandler import TICKER_HANDLER
+
+ # call tick myobj.at_tick(*args, **kwargs) every 15 seconds
+ TICKER_HANDLER.add(15, myobj.at_tick, *args, **kwargs)
+```
+
+You supply the interval to tick and a callable to call regularly with
+any extra args/kwargs. The callable should either be a stand-alone
+function in a module *or* the method on a *typeclassed* entity (that
+is, on an object that can be safely and stably returned from the
+database). Functions that are dynamically created or sits on
+in-memory objects cannot be used by the tickerhandler (there is no way
+to reference them safely across reboots and saves).
+
+The handler will transparently set
+up and add new timers behind the scenes to tick at given intervals,
+using a TickerPool - all callables with the same interval will share
+the interval ticker.
+
+To remove:
+
+```python
+ TICKER_HANDLER.remove(15, myobj.at_tick)
+```
+
+Both interval and callable must be given since a single object can be subscribed
+to many different tickers at the same time. You can also supply `idstring`
+as an identifying string if you ever want to tick the callable at the same interval
+but with different arguments (args/kwargs are not used for identifying the ticker). There
+is also `persistent=False` if you don't want to make a ticker that don't survive a reload.
+If either or both `idstring` or `persistent` has been changed from their defaults, they
+must be supplied to the `TICKER_HANDLER.remove` call to properly identify the ticker
+to remove.
+
+The TickerHandler's functionality can be overloaded by modifying the
+Ticker class and then changing TickerPool and TickerHandler to use the
+custom classes
+
+```python
+class MyTicker(Ticker):
+ # [doing custom stuff]
+
+class MyTickerPool(TickerPool):
+ ticker_class = MyTicker
+class MyTickerHandler(TickerHandler):
+ ticker_pool_class = MyTickerPool
+```
+
+If one wants to duplicate TICKER_HANDLER's auto-saving feature in
+a custom handler one can make a custom `AT_STARTSTOP_MODULE` entry to
+call the handler's `save()` and `restore()` methods when the server reboots.
+
+"""
+importinspect
+
+fromtwisted.internet.deferimportinlineCallbacks
+fromdjango.core.exceptionsimportObjectDoesNotExist
+fromevennia.scripts.scriptsimportExtendedLoopingCall
+fromevennia.server.modelsimportServerConfig
+fromevennia.utils.loggerimportlog_trace,log_err
+fromevennia.utils.dbserializeimportdbserialize,dbunserialize,pack_dbobj
+fromevennia.utilsimportvariable_from_module,inherits_from
+
+_GA=object.__getattribute__
+_SA=object.__setattr__
+
+
+_ERROR_ADD_TICKER="""TickerHandler: Tried to add an invalid ticker:
+{store_key}
+Ticker was not added."""
+
+_ERROR_ADD_TICKER_SUB_SECOND="""You are trying to add a ticker running faster
+than once per second. This is not supported and also probably not useful:
+Spamming messages to the user faster than once per second serves no purpose in
+a text-game, and if you want to update some property, consider doing so
+on-demand rather than using a ticker.
+"""
+
+
[docs]classTicker(object):
+ """
+ Represents a repeatedly running task that calls
+ hooks repeatedly. Overload `_callback` to change the
+ way it operates.
+ """
+
+ @inlineCallbacks
+ def_callback(self):
+ """
+ This will be called repeatedly every `self.interval` seconds.
+ `self.subscriptions` contain tuples of (obj, args, kwargs) for
+ each subscribing object.
+
+ If overloading, this callback is expected to handle all
+ subscriptions when it is triggered. It should not return
+ anything and should not traceback on poorly designed hooks.
+ The callback should ideally work under @inlineCallbacks so it
+ can yield appropriately.
+
+ The _hook_key, which is passed down through the handler via
+ kwargs is used here to identify which hook method to call.
+
+ """
+ self._to_add=[]
+ self._to_remove=[]
+ self._is_ticking=True
+ forstore_key,(args,kwargs)inself.subscriptions.items():
+ callback=yieldkwargs.pop("_callback","at_tick")
+ obj=yieldkwargs.pop("_obj",None)
+ try:
+ ifcallable(callback):
+ # call directly
+ yieldcallback(*args,**kwargs)
+ continue
+ # try object method
+ ifnotobjornotobj.pk:
+ # object was deleted between calls
+ self._to_remove.append(store_key)
+ continue
+ else:
+ yield_GA(obj,callback)(*args,**kwargs)
+ exceptObjectDoesNotExist:
+ log_trace("Removing ticker.")
+ self._to_remove.append(store_key)
+ exceptException:
+ log_trace()
+ finally:
+ # make sure to re-store
+ kwargs["_callback"]=callback
+ kwargs["_obj"]=obj
+ # cleanup - we do this here to avoid changing the subscription dict while it loops
+ self._is_ticking=False
+ forstore_keyinself._to_remove:
+ self.remove(store_key)
+ forstore_key,(args,kwargs)inself._to_add:
+ self.add(store_key,*args,**kwargs)
+ self._to_remove=[]
+ self._to_add=[]
+
+
[docs]def__init__(self,interval):
+ """
+ Set up the ticker
+
+ Args:
+ interval (int): The stepping interval.
+
+ """
+ self.interval=interval
+ self.subscriptions={}
+ self._is_ticking=False
+ self._to_remove=[]
+ self._to_add=[]
+ # set up a twisted asynchronous repeat call
+ self.task=ExtendedLoopingCall(self._callback)
+
+
[docs]defvalidate(self,start_delay=None):
+ """
+ Start/stop the task depending on how many subscribers we have
+ using it.
+
+ Args:
+ start_delay (int): Time to way before starting.
+
+ """
+ subs=self.subscriptions
+ ifself.task.running:
+ ifnotsubs:
+ self.task.stop()
+ elifsubs:
+ self.task.start(self.interval,now=False,start_delay=start_delay)
+
+
[docs]defadd(self,store_key,*args,**kwargs):
+ """
+ Sign up a subscriber to this ticker.
+ Args:
+ store_key (str): Unique storage hash for this ticker subscription.
+ args (any, optional): Arguments to call the hook method with.
+
+ Keyword Args:
+ _start_delay (int): If set, this will be
+ used to delay the start of the trigger instead of
+ `interval`.
+
+ """
+ ifself._is_ticking:
+ # protects the subscription dict from
+ # updating while it is looping
+ self._to_add.append((store_key,(args,kwargs)))
+ else:
+ start_delay=kwargs.pop("_start_delay",None)
+ self.subscriptions[store_key]=(args,kwargs)
+ self.validate(start_delay=start_delay)
+
+
[docs]defremove(self,store_key):
+ """
+ Unsubscribe object from this ticker
+
+ Args:
+ store_key (str): Unique store key.
+
+ """
+ ifself._is_ticking:
+ # this protects the subscription dict from
+ # updating while it is looping
+ self._to_remove.append(store_key)
+ else:
+ self.subscriptions.pop(store_key,False)
+ self.validate()
+
+
[docs]defstop(self):
+ """
+ Kill the Task, regardless of subscriptions.
+
+ """
+ self.subscriptions={}
+ self.validate()
+
+
+
[docs]classTickerPool(object):
+ """
+ This maintains a pool of
+ `evennia.scripts.scripts.ExtendedLoopingCall` tasks for calling
+ subscribed objects at given times.
+
+ """
+
+ ticker_class=Ticker
+
+
[docs]def__init__(self):
+ """
+ Initialize the pool.
+
+ """
+ self.tickers={}
[docs]defstop(self,interval=None):
+ """
+ Stop all scripts in pool. This is done at server reload since
+ restoring the pool will automatically re-populate the pool.
+
+ Args:
+ interval (int, optional): Only stop tickers with this
+ interval.
+
+ """
+ ifintervalandintervalinself.tickers:
+ self.tickers[interval].stop()
+ else:
+ fortickerinself.tickers.values():
+ ticker.stop()
+
+
+
[docs]classTickerHandler(object):
+ """
+ The Tickerhandler maintains a pool of tasks for subscribing
+ objects to various tick rates. The pool maintains creation
+ instructions and and re-applies them at a server restart.
+
+ """
+
+ ticker_pool_class=TickerPool
+
+
[docs]def__init__(self,save_name="ticker_storage"):
+ """
+ Initialize handler
+
+ save_name (str, optional): The name of the ServerConfig
+ instance to store the handler state persistently.
+
+ """
+ self.ticker_storage={}
+ self.save_name=save_name
+ self.ticker_pool=self.ticker_pool_class()
+
+ def_get_callback(self,callback):
+ """
+ Analyze callback and determine its consituents
+
+ Args:
+ callback (function or method): This is either a stand-alone
+ function or class method on a typeclassed entitye (that is,
+ an entity that can be saved to the database).
+ Returns:
+ ret (tuple): This is a tuple of the form `(obj, path, callfunc)`,
+ where `obj` is the database object the callback is defined on
+ if it's a method (otherwise `None`) and vice-versa, `path` is
+ the python-path to the stand-alone function (`None` if a method).
+ The `callfunc` is either the name of the method to call or the
+ callable function object itself.
+ Raises:
+ TypeError: If the callback is of an unsupported type.
+
+ """
+ outobj,outpath,outcallfunc=None,None,None
+ ifcallable(callback):
+ ifinspect.ismethod(callback):
+ outobj=callback.__self__
+ outcallfunc=callback.__func__.__name__
+ elifinspect.isfunction(callback):
+ outpath="%s.%s"%(callback.__module__,callback.__name__)
+ outcallfunc=callback
+ else:
+ raiseTypeError(f"{callback} is not a method or function.")
+ else:
+ raiseTypeError(f"{callback} is not a callable function or method.")
+
+ ifoutobjandnotinherits_from(outobj,"evennia.typeclasses.models.TypedObject"):
+ raiseTypeError(
+ f"{callback} is a method on a normal object - it must "
+ "be either a method on a typeclass, or a stand-alone function."
+ )
+
+ returnoutobj,outpath,outcallfunc
+
+ def_store_key(self,obj,path,interval,callfunc,idstring="",persistent=True):
+ """
+ Tries to create a store_key for the object.
+
+ Args:
+ obj (Object, tuple or None): Subscribing object if any. If a tuple, this is
+ a packed_obj tuple from dbserialize.
+ path (str or None): Python-path to callable, if any.
+ interval (int): Ticker interval. Floats will be converted to
+ nearest lower integer value.
+ callfunc (callable or str): This is either the callable function or
+ the name of the method to call. Note that the callable is never
+ stored in the key; that is uniquely identified with the python-path.
+ idstring (str, optional): Additional separator between
+ different subscription types.
+ persistent (bool, optional): If this ticker should survive a system
+ shutdown or not.
+
+ Returns:
+ store_key (tuple): A tuple `(packed_obj, methodname, outpath, interval,
+ idstring, persistent)` that uniquely identifies the
+ ticker. Here, `packed_obj` is the unique string representation of the
+ object or `None`. The `methodname` is the string name of the method on
+ `packed_obj` to call, or `None` if `packed_obj` is unset. `path` is
+ the Python-path to a non-method callable, or `None`. Finally, `interval`
+ `idstring` and `persistent` are integers, strings and bools respectively.
+
+ """
+ ifinterval<1:
+ raiseRuntimeError(_ERROR_ADD_TICKER_SUB_SECOND)
+
+ interval=int(interval)
+ persistent=bool(persistent)
+ packed_obj=pack_dbobj(obj)
+ methodname=callfuncifcallfuncandisinstance(callfunc,str)elseNone
+ outpath=pathifpathandisinstance(path,str)elseNone
+ return(packed_obj,methodname,outpath,interval,idstring,persistent)
+
+
[docs]defsave(self):
+ """
+ Save ticker_storage as a serialized string into a temporary
+ ServerConf field. Whereas saving is done on the fly, if called
+ by server when it shuts down, the current timer of each ticker
+ will be saved so it can start over from that point.
+
+ """
+ ifself.ticker_storage:
+ # get the current times so the tickers can be restarted with a delay later
+ start_delays=dict(
+ (interval,ticker.task.next_call_time())
+ forinterval,tickerinself.ticker_pool.tickers.items()
+ )
+
+ # remove any subscriptions that lost its object in the interim
+ to_save={
+ store_key:(args,kwargs)
+ forstore_key,(args,kwargs)inself.ticker_storage.items()
+ if(
+ (
+ store_key[1]
+ and("_obj"inkwargsandkwargs["_obj"].pk)
+ andhasattr(kwargs["_obj"],store_key[1])
+ )
+ orstore_key[2]# a valid method with existing obj
+ )
+ }# a path given
+
+ # update the timers for the tickers
+ forstore_key,(args,kwargs)into_save.items():
+ interval=store_key[1]
+ # this is a mutable, so it's updated in-place in ticker_storage
+ kwargs["_start_delay"]=start_delays.get(interval,None)
+ ServerConfig.objects.conf(key=self.save_name,value=dbserialize(to_save))
+ else:
+ # make sure we have nothing lingering in the database
+ ServerConfig.objects.conf(key=self.save_name,delete=True)
+
+
[docs]defrestore(self,server_reload=True):
+ """
+ Restore ticker_storage from database and re-initialize the
+ handler from storage. This is triggered by the server at
+ restart.
+
+ Args:
+ server_reload (bool, optional): If this is False, it means
+ the server went through a cold reboot and all
+ non-persistent tickers must be killed.
+
+ """
+ # load stored command instructions and use them to re-initialize handler
+ restored_tickers=ServerConfig.objects.conf(key=self.save_name)
+ ifrestored_tickers:
+ # the dbunserialize will convert all serialized dbobjs to real objects
+
+ restored_tickers=dbunserialize(restored_tickers)
+ self.ticker_storage={}
+ forstore_key,(args,kwargs)inrestored_tickers.items():
+ try:
+ # at this point obj is the actual object (or None) due to how
+ # the dbunserialize works
+ obj,callfunc,path,interval,idstring,persistent=store_key
+ ifnotpersistentandnotserver_reload:
+ # this ticker will not be restarted
+ continue
+ ifisinstance(callfunc,str)andnotobj:
+ # methods must have an existing object
+ continue
+ # we must rebuild the store_key here since obj must not be
+ # stored as the object itself for the store_key to be hashable.
+ store_key=self._store_key(obj,path,interval,callfunc,idstring,persistent)
+
+ ifobjandcallfunc:
+ kwargs["_callback"]=callfunc
+ kwargs["_obj"]=obj
+ elifpath:
+ modname,varname=path.rsplit(".",1)
+ callback=variable_from_module(modname,varname)
+ kwargs["_callback"]=callback
+ kwargs["_obj"]=None
+ else:
+ # Neither object nor path - discard this ticker
+ log_err("Tickerhandler: Removing malformed ticker: %s"%str(store_key))
+ continue
+ exceptException:
+ # this suggests a malformed save or missing objects
+ log_trace("Tickerhandler: Removing malformed ticker: %s"%str(store_key))
+ continue
+ # if we get here we should create a new ticker
+ self.ticker_storage[store_key]=(args,kwargs)
+ self.ticker_pool.add(store_key,*args,**kwargs)
+
+
[docs]defadd(self,interval=60,callback=None,idstring="",persistent=True,*args,**kwargs):
+ """
+ Add subscription to tickerhandler
+
+ Args:
+ interval (int, optional): Interval in seconds between calling
+ `callable(*args, **kwargs)`
+ callable (callable function or method, optional): This
+ should either be a stand-alone function or a method on a
+ typeclassed entity (that is, one that can be saved to the
+ database).
+ idstring (str, optional): Identifier for separating
+ this ticker-subscription from others with the same
+ interval. Allows for managing multiple calls with
+ the same time interval and callback.
+ persistent (bool, optional): A ticker will always survive
+ a server reload. If this is unset, the ticker will be
+ deleted by a server shutdown.
+ args, kwargs (optional): These will be passed into the
+ callback every time it is called. This must be data possible
+ to pickle!
+
+ Returns:
+ store_key (tuple): The immutable store-key for this ticker. This can
+ be stored and passed into `.remove(store_key=store_key)` later to
+ easily stop this ticker later.
+
+ Notes:
+ The callback will be identified by type and stored either as
+ as combination of serialized database object + methodname or
+ as a python-path to the module + funcname. These strings will
+ be combined iwth `interval` and `idstring` to define a
+ unique storage key for saving. These must thus all be supplied
+ when wanting to modify/remove the ticker later.
+
+ """
+ obj,path,callfunc=self._get_callback(callback)
+ store_key=self._store_key(obj,path,interval,callfunc,idstring,persistent)
+ kwargs["_obj"]=obj
+ kwargs["_callback"]=callfunc# either method-name or callable
+ self.ticker_storage[store_key]=(args,kwargs)
+ self.ticker_pool.add(store_key,*args,**kwargs)
+ self.save()
+ returnstore_key
+
+
[docs]defremove(self,interval=60,callback=None,idstring="",persistent=True,store_key=None):
+ """
+ Remove ticker subscription from handler.
+
+ Args:
+ interval (int, optional): Interval of ticker to remove.
+ callback (callable function or method): Either a function or
+ the method of a typeclassed object.
+ idstring (str, optional): Identifier id of ticker to remove.
+ persistent (bool, optional): Whether this ticker is persistent or not.
+ store_key (str, optional): If given, all other kwargs are ignored and only
+ this is used to identify the ticker.
+
+ Raises:
+ KeyError: If no matching ticker was found to remove.
+
+ Notes:
+ The store-key is normally built from the interval/callback/idstring/persistent values;
+ but if the `store_key` is explicitly given, this is used instead.
+
+ """
+ ifisinstance(callback,int):
+ raiseRuntimeError(
+ "TICKER_HANDLER.remove has changed: "
+ "the interval is now the first argument, callback the second."
+ )
+ ifnotstore_key:
+ obj,path,callfunc=self._get_callback(callback)
+ store_key=self._store_key(obj,path,interval,callfunc,idstring,persistent)
+ to_remove=self.ticker_storage.pop(store_key,None)
+ ifto_remove:
+ self.ticker_pool.remove(store_key)
+ self.save()
+ else:
+ raiseKeyError(f"No Ticker was found matching the store-key {store_key}.")
+
+
[docs]defclear(self,interval=None):
+ """
+ Stop/remove tickers from handler.
+
+ Args:
+ interval (int): Only stop tickers with this interval.
+
+ Notes:
+ This is the only supported way to kill tickers related to
+ non-db objects.
+
+ """
+ self.ticker_pool.stop(interval)
+ ifinterval:
+ self.ticker_storage=dict(
+ (store_key,store_key)
+ forstore_keyinself.ticker_storage
+ ifstore_key[1]!=interval
+ )
+ else:
+ self.ticker_storage={}
+ self.save()
+
+
[docs]defall(self,interval=None):
+ """
+ Get all subscriptions.
+
+ Args:
+ interval (int): Limit match to tickers with this interval.
+
+ Returns:
+ tickers (list): If `interval` was given, this is a list of
+ tickers using that interval.
+ tickerpool_layout (dict): If `interval` was *not* given,
+ this is a dict {interval1: [ticker1, ticker2, ...], ...}
+
+ """
+ ifintervalisNone:
+ # return dict of all, ordered by interval
+ returndict(
+ (interval,ticker.subscriptions)
+ forinterval,tickerinself.ticker_pool.tickers.items()
+ )
+ else:
+ # get individual interval
+ ticker=self.ticker_pool.tickers.get(interval,None)
+ ifticker:
+ return{interval:ticker.subscriptions}
+ returnNone
+
+
[docs]defall_display(self):
+ """
+ Get all tickers on an easily displayable form.
+
+ Returns:
+ tickers (dict): A list of all storekeys
+
+ """
+ store_keys=[]
+ fortickerinself.ticker_pool.tickers.values():
+ for(
+ (objtup,callfunc,path,interval,idstring,persistent),
+ (args,kwargs),
+ )inticker.subscriptions.items():
+ store_keys.append(
+ (kwargs.get("_obj",None),callfunc,path,interval,idstring,persistent)
+ )
+ returnstore_keys
+
+
+# main tickerhandler
+TICKER_HANDLER=TickerHandler()
+
+#
+# This sets up how models are displayed
+# in the web admin interface.
+#
+
+fromdjango.contribimportadmin
+fromevennia.server.modelsimportServerConfig
+
+
+
+"""
+The Evennia Server service acts as an AMP-client when talking to the
+Portal. This module sets up the Client-side communication.
+
+"""
+
+importos
+fromevennia.server.portalimportamp
+fromtwisted.internetimportprotocol
+fromevennia.utilsimportlogger
+
+
+
[docs]classAMPClientFactory(protocol.ReconnectingClientFactory):
+ """
+ This factory creates an instance of an AMP client connection. This handles communication from
+ the be the Evennia 'Server' service to the 'Portal'. The client will try to auto-reconnect on a
+ connection error.
+
+ """
+
+ # Initial reconnect delay in seconds.
+ initialDelay=1
+ factor=1.5
+ maxDelay=1
+ noisy=False
+
+
[docs]def__init__(self,server):
+ """
+ Initializes the client factory.
+
+ Args:
+ server (server): server instance.
+
+ """
+ self.server=server
+ self.protocol=AMPServerClientProtocol
+ self.maxDelay=10
+ # not really used unless connecting to multiple servers, but
+ # avoids having to check for its existence on the protocol
+ self.broadcasts=[]
+
+
[docs]defstartedConnecting(self,connector):
+ """
+ Called when starting to try to connect to the Portal AMP server.
+
+ Args:
+ connector (Connector): Twisted Connector instance representing
+ this connection.
+
+ """
+ pass
+
+
[docs]defbuildProtocol(self,addr):
+ """
+ Creates an AMPProtocol instance when connecting to the AMP server.
+
+ Args:
+ addr (str): Connection address. Not used.
+
+ """
+ self.resetDelay()
+ self.server.amp_protocol=AMPServerClientProtocol()
+ self.server.amp_protocol.factory=self
+ returnself.server.amp_protocol
+
+
[docs]defclientConnectionLost(self,connector,reason):
+ """
+ Called when the AMP connection to the MUD server is lost.
+
+ Args:
+ connector (Connector): Twisted Connector instance representing
+ this connection.
+ reason (str): Eventual text describing why connection was lost.
+
+ """
+ logger.log_info("Server disconnected from the portal.")
+ protocol.ReconnectingClientFactory.clientConnectionLost(self,connector,reason)
+
+
[docs]defclientConnectionFailed(self,connector,reason):
+ """
+ Called when an AMP connection attempt to the MUD server fails.
+
+ Args:
+ connector (Connector): Twisted Connector instance representing
+ this connection.
+ reason (str): Eventual text describing why connection failed.
+
+ """
+ logger.log_msg("Attempting to reconnect to Portal ...")
+ protocol.ReconnectingClientFactory.clientConnectionFailed(self,connector,reason)
+
+
+
[docs]classAMPServerClientProtocol(amp.AMPMultiConnectionProtocol):
+ """
+ This protocol describes the Server service (acting as an AMP-client)'s communication with the
+ Portal (which acts as the AMP-server)
+
+ """
+
+ # sending AMP data
+
+
[docs]defconnectionMade(self):
+ """
+ Called when a new connection is established.
+
+ """
+ # print("AMPClient new connection {}".format(self))
+ info_dict=self.factory.server.get_info_dict()
+ super(AMPServerClientProtocol,self).connectionMade()
+ # first thing we do is to request the Portal to sync all sessions
+ # back with the Server side. We also need the startup mode (reload, reset, shutdown)
+ self.send_AdminServer2Portal(
+ amp.DUMMYSESSION,operation=amp.PSYNC,spid=os.getpid(),info_dict=info_dict
+ )
+ # run the intial setup if needed
+ self.factory.server.run_initial_setup()
+
+
[docs]defdata_to_portal(self,command,sessid,**kwargs):
+ """
+ Send data across the wire to the Portal
+
+ Args:
+ command (AMP Command): A protocol send command.
+ sessid (int): A unique Session id.
+ kwargs (any): Any data to pickle into the command.
+
+ Returns:
+ deferred (deferred or None): A deferred with an errback.
+
+ Notes:
+ Data will be sent across the wire pickled as a tuple
+ (sessid, kwargs).
+
+ """
+ # print("server data_to_portal: {}, {}, {}".format(command, sessid, kwargs))
+ returnself.callRemote(command,packed_data=amp.dumps((sessid,kwargs))).addErrback(
+ self.errback,command.key
+ )
+
+
[docs]defsend_MsgServer2Portal(self,session,**kwargs):
+ """
+ Access method - executed on the Server for sending data
+ to Portal.
+
+ Args:
+ session (Session): Unique Session.
+ kwargs (any, optiona): Extra data.
+
+ """
+ returnself.data_to_portal(amp.MsgServer2Portal,session.sessid,**kwargs)
+
+
[docs]defsend_AdminServer2Portal(self,session,operation="",**kwargs):
+ """
+ Administrative access method called by the Server to send an
+ instruction to the Portal.
+
+ Args:
+ session (Session): Session.
+ operation (char, optional): Identifier for the server
+ operation, as defined by the global variables in
+ `evennia/server/amp.py`.
+ kwargs (dict, optional): Data going into the adminstrative.
+
+ """
+ returnself.data_to_portal(
+ amp.AdminServer2Portal,session.sessid,operation=operation,**kwargs
+ )
[docs]defask_continue(self):
+ "'Press return to continue'-prompt"
+ input(" (Press return to continue)")
+
+
[docs]defask_node(self,options,prompt="Enter choice: ",default=None):
+ """
+ Retrieve options and jump to different menu nodes
+
+ Args:
+ options (dict): Node options on the form {key: (desc, callback), }
+ prompt (str, optional): Question to ask
+ default (str, optional): Default value to use if user hits return.
+
+ """
+
+ opt_txt="\n".join(f" {key}: {desc}"forkey,(desc,_,_)inoptions.items())
+ self.display(opt_txt+"\n")
+
+ whileTrue:
+ resp=input(prompt).strip()
+
+ ifnotresp:
+ ifdefault:
+ resp=str(default)
+
+ ifresp.lower()inoptions:
+ # self.display(f" Selected '{resp}'.")
+ desc,callback,kwargs=options[resp.lower()]
+ callback(self,**kwargs)
+ elifresp.lower()in("quit","q"):
+ sys.exit()
+ elifresp:
+ # input, but nothing was recognized
+ self.display(" Choose one of: {}".format(list_to_string(list(options))))
+
+
[docs]defask_yesno(self,prompt,default="yes"):
+ """
+ Ask a yes/no question inline.
+
+ Keyword Args:
+ prompt (str): The prompt to ask.
+ default (str): "yes" or "no", used if pressing return.
+ Returns:
+ reply (str): Either 'yes' or 'no'.
+
+ """
+ prompt=prompt+(" [Y]/N? "ifdefault=="yes"else" Y/[N]? ")
+
+ whileTrue:
+ resp=input(prompt).lstrip().lower()
+ ifnotresp:
+ resp=default.lower()
+ ifrespin("yes","y"):
+ self.display(" Answered Yes.")
+ return"yes"
+ elifrespin("no","n"):
+ self.display(" Answered No.")
+ return"no"
+ elifresp.lower()in("quit","q"):
+ sys.exit()
+
+
[docs]defask_choice(self,prompt=" > ",options=None,default=None):
+ """
+ Ask multiple-choice question, get response inline.
+
+ Keyword Args:
+ prompt (str): Input prompt.
+ options (list): List of options. Will be indexable by sequence number 1...
+ default (int): The list index+1 of the default choice, if any
+ Returns:
+ reply (str): The answered reply.
+
+ """
+ opt_txt="\n".join(f" {ind+1}: {desc}"forind,descinenumerate(options))
+ self.display(opt_txt+"\n")
+
+ whileTrue:
+ resp=input(prompt).strip()
+
+ ifnotresp:
+ ifdefault:
+ returnoptions[int(default)]
+ ifresp.lower()in("quit","q"):
+ sys.exit()
+ ifresp.isdigit():
+ resp=int(resp)-1
+ if0<=resp<len(options):
+ selection=options[resp]
+ self.display(f" Selected '{selection}'.")
+ returnselection
+ self.display(" Select one of the given options.")
+
+
[docs]defask_input(self,prompt=" > ",default=None,validator=None):
+ """
+ Get arbitrary input inline.
+
+ Keyword Args:
+ prompt (str): The display prompt.
+ default (str): If empty input, use this.
+ validator (callable): If given, the input will be passed
+ into this callable. It should return True unless validation
+ fails (and is expected to echo why if so).
+
+ Returns:
+ inp (str): The input given, or default.
+
+ """
+ whileTrue:
+ resp=input(prompt).strip()
+
+ ifnotrespanddefault:
+ resp=str(default)
+
+ ifresp.lower()in("q","quit"):
+ sys.exit()
+
+ ifresp.lower()=="none":
+ resp=""
+
+ ifvalidatorandnotvalidator(resp):
+ continue
+
+ ok=input("\n Leave blank? [Y]/N: ")
+ ifok.lower()in("n","no"):
+ continue
+ elifok.lower()in("q","quit"):
+ sys.exit()
+ returnresp
+
+ ifvalidatorandnotvalidator(resp):
+ continue
+
+ self.display(resp)
+ ok=input("\n Is this correct? [Y]/N: ")
+ ifok.lower()in("n","no"):
+ continue
+ elifok.lower()in("q","quit"):
+ sys.exit()
+ returnresp
+
+
+
[docs]defnode_start(wizard):
+ text="""
+ This wizard helps to attach your Evennia server to external networks. It
+ will save to a file `server/conf/connection_settings.py` that will be
+ imported from the bottom of your game settings file. Once generated you can
+ also modify that file directly.
+
+ Make sure you have at least started the game once before continuing!
+
+ Use `quit` at any time to abort and throw away unsaved changes.
+ """
+ options={
+ "1":(
+ "Add your game to the Evennia game index (also for closed-dev games)",
+ node_game_index_start,
+ {},
+ ),
+ "2":("MSSP setup (for mud-list crawlers)",node_mssp_start,{}),
+ # "3": ("Add Grapevine listing",
+ # node_grapevine_start, {}),
+ # "4": ("Add IRC link",
+ # "node_irc_start", {}),
+ # "5" ("Add RSS feed",
+ # "node_rss_start", {}),
+ "s":("View and (optionally) Save created settings",node_view_and_apply_settings,{}),
+ "q":("Quit",lambda*args:sys.exit(),{}),
+ }
+
+ wizard.display(text)
+ wizard.ask_node(options)
+
+
+# Evennia game index
+
+
+
[docs]defnode_game_index_start(wizard,**kwargs):
+ text="""
+ The Evennia game index (http://games.evennia.com) lists both active Evennia
+ games as well as games in various stages of development.
+
+ You can put up your game in the index also if you are not (yet) open for
+ players. If so, put 'None' for the connection details - you are just telling
+ us that you are out there, making us excited about your upcoming game!
+
+ Please check the listing online first to see that your exact game name is
+ not colliding with an existing game-name in the list (be nice!).
+ """
+
+ wizard.display(text)
+ ifwizard.ask_yesno("Continue adding/editing an Index entry?")=="yes":
+ node_game_index_fields(wizard)
+ else:
+ node_start(wizard)
+
+
+
[docs]defnode_game_index_fields(wizard,status=None):
+
+ # reset the listing if needed
+ ifnothasattr(wizard,"game_index_listing"):
+ wizard.game_index_listing=settings.GAME_INDEX_LISTING
+
+ # game status
+
+ status_default=wizard.game_index_listing["game_status"]
+ text=f"""
+ What is the status of your game?
+ - pre-alpha: a game in its very early stages, mostly unfinished or unstarted
+ - alpha: a working concept, probably lots of bugs and incomplete features
+ - beta: a working game, but expect bugs and changing features
+ - launched: a full, working game (that may still be expanded upon and improved later)
+
+ Current value (return to keep):
+{status_default}
+ """
+
+ options=["pre-alpha","alpha","beta","launched"]
+
+ wizard.display(text)
+ wizard.game_index_listing["game_status"]=wizard.ask_choice("Select one: ",options)
+
+ # game name
+
+ name_default=settings.SERVERNAME
+ text=f"""
+ Your game's name should usually be the same as `settings.SERVERNAME`, but
+ you can set it to something else here if you want.
+
+ Current value:
+{name_default}
+ """
+
+ defname_validator(inp):
+ tmax=80
+ tlen=len(inp)
+ iftlen>tmax:
+ print(f"The name must be shorter than {tmax} characters (was {tlen}).")
+ wizard.ask_continue()
+ returnFalse
+ returnTrue
+
+ wizard.display(text)
+ wizard.game_index_listing["game_name"]=wizard.ask_input(
+ default=name_default,validator=name_validator
+ )
+
+ # short desc
+
+ sdesc_default=wizard.game_index_listing.get("short_description",None)
+
+ text=f"""
+ Enter a short description of your game. Make it snappy and interesting!
+ This should be at most one or two sentences (255 characters) to display by
+ '{settings.SERVERNAME}' in the main game list. Line breaks will be ignored.
+
+ Current value:
+{sdesc_default}
+ """
+
+ defsdesc_validator(inp):
+ tmax=255
+ tlen=len(inp)
+ iftlen>tmax:
+ print(f"The short desc must be shorter than {tmax} characters (was {tlen}).")
+ wizard.ask_continue()
+ returnFalse
+ returnTrue
+
+ wizard.display(text)
+ wizard.game_index_listing["short_description"]=wizard.ask_input(
+ default=sdesc_default,validator=sdesc_validator
+ )
+
+ # long desc
+
+ long_default=wizard.game_index_listing.get("long_description",None)
+
+ text=f"""
+ Enter a longer, full-length description. This will be shown when clicking
+ on your game's listing. You can use \\n to create line breaks and may use
+ Markdown formatting like *bold*, _italic_, [linkname](http://link) etc.
+
+ Current value:
+{long_default}
+ """
+
+ wizard.display(text)
+ wizard.game_index_listing["long_description"]=wizard.ask_input(default=long_default)
+
+ # listing contact
+
+ listing_default=wizard.game_index_listing.get("listing_contact",None)
+ text=f"""
+ Enter a listing email-contact. This will not be visible in the listing, but
+ allows us to get in touch with you should there be some listing issue (like
+ a name collision) or some bug with the listing (us actually using this is
+ likely to be somewhere between super-rarely and never).
+
+ Current value:
+{listing_default}
+ """
+
+ defcontact_validator(inp):
+ ifnotinpor"@"notininp:
+ print("This should be an email and cannot be blank.")
+ wizard.ask_continue()
+ returnFalse
+ returnTrue
+
+ wizard.display(text)
+ wizard.game_index_listing["listing_contact"]=wizard.ask_input(
+ default=listing_default,validator=contact_validator
+ )
+
+ # telnet hostname
+
+ hostname_default=wizard.game_index_listing.get("telnet_hostname",None)
+ text=f"""
+ Enter the hostname to which third-party telnet mud clients can connect to
+ your game. This would be the name of the server your game is hosted on,
+ like `coolgame.games.com`, or `mygreatgame.se`.
+
+ Write 'None' if you are not offering public telnet connections at this time.
+
+ Current value:
+{hostname_default}
+ """
+
+ wizard.display(text)
+ wizard.game_index_listing["telnet_hostname"]=wizard.ask_input(default=hostname_default)
+
+ # telnet port
+
+ port_default=wizard.game_index_listing.get("telnet_port",None)
+ text=f"""
+ Enter the main telnet port. The Evennia default is 4000. You can change
+ this with the TELNET_PORTS server setting.
+
+ Write 'None' if you are not offering public telnet connections at this time.
+
+ Current value:
+{port_default}
+ """
+
+ wizard.display(text)
+ wizard.game_index_listing["telnet_port"]=wizard.ask_input(default=port_default)
+
+ # website
+
+ website_default=wizard.game_index_listing.get("game_website",None)
+ text=f"""
+ Evennia is its own web server and runs your game's website. Enter the
+ URL of the website here, like http://yourwebsite.com, here.
+
+ Write 'None' if you are not offering a publicly visible website at this time.
+
+ Current value:
+{website_default}
+ """
+
+ wizard.display(text)
+ wizard.game_index_listing["game_website"]=wizard.ask_input(default=website_default)
+
+ # webclient
+
+ webclient_default=wizard.game_index_listing.get("web_client_url",None)
+ text=f"""
+ Evennia offers its own native webclient. Normally it will be found from the
+ game homepage at something like http://yourwebsite.com/webclient. Enter
+ your specific URL here (when clicking this link you should launch into the
+ web client)
+
+ Write 'None' if you don't want to list a publicly accessible webclient.
+
+ Current value:
+{webclient_default}
+ """
+
+ wizard.display(text)
+ wizard.game_index_listing["web_client_url"]=wizard.ask_input(default=webclient_default)
+
+ ifnot(
+ wizard.game_index_listing.get("web_client_url")
+ or(wizard.game_index_listing.get("telnet_host"))
+ ):
+ wizard.display(
+ "\nNote: You have not specified any connection options. This means "
+ "your game \nwill be marked as being in 'closed development' in "
+ "the index."
+ )
+
+ wizard.display("\nDon't forget to inspect and save your changes.")
+
+ node_start(wizard)
+
+
+# MSSP
+
+
+
[docs]defnode_mssp_start(wizard):
+
+ mssp_module=mod_import(settings.MSSP_META_MODULEor"server.conf.mssp")
+ try:
+ filename=mssp_module.__file__
+ exceptAttributeError:
+ filename="server/conf/mssp.py"
+
+ text=f"""
+ MSSP (Mud Server Status Protocol) has a vast amount of options so it must
+ be modified outside this wizard by directly editing its config file here:
+
+ '{filename}'
+
+ MSSP allows traditional online MUD-listing sites/crawlers to continuously
+ monitor your game and list information about it. Some of this, like active
+ player-count, Evennia will automatically add for you, whereas most fields
+ you need to set manually.
+
+ To use MSSP you should generally have a publicly open game that external
+ players can connect to. You also need to register at a MUD listing site to
+ tell them to crawl your game.
+ """
+
+ wizard.display(text)
+ wizard.ask_continue()
+ node_start(wizard)
+
+
+# Admin
+
+
+def_save_changes(wizard):
+ """
+ Perform the save
+ """
+
+ # add import statement to settings file
+ import_stanza="from .connection_settings import *"
+ setting_module=mod_import("server.conf.settings")
+ withopen(setting_module.__file__,"r+")asf:
+ txt=f.read()# moves pointer to end of file
+ ifimport_stanzanotintxt:
+ # add to the end of the file
+ f.write(
+ "\n\n"
+ "try:\n"
+ " # Created by the `evennia connections` wizard\n"
+ f" {import_stanza}\n"
+ "except ImportError:\n"
+ " pass"
+ )
+
+ connect_settings_file=path.join(settings.GAME_DIR,"server","conf","connection_settings.py")
+ withopen(connect_settings_file,"w")asf:
+ f.write(
+ "# This file is auto-generated by the `evennia connections` wizard.\n"
+ "# Don't edit manually, your changes will be overwritten.\n\n"
+ )
+
+ f.write(wizard.save_output)
+ wizard.display(f"saving to {connect_settings_file} ...")
+
+
+
[docs]defnode_view_and_apply_settings(wizard):
+ """
+ Inspect and save the data gathered in the other nodes
+
+ """
+ pp=pprint.PrettyPrinter(indent=4)
+ saves=False
+
+ # game index
+ game_index_save_text=""
+ game_index_listing=(
+ wizard.game_index_listingifhasattr(wizard,"game_index_listing")elseNone
+ )
+ ifnotgame_index_listingandsettings.GAME_INDEX_ENABLED:
+ game_index_listing=settings.GAME_INDEX_LISTING
+ ifgame_index_listing:
+ game_index_save_text=(
+ "GAME_INDEX_ENABLED = True\n"
+ "GAME_INDEX_LISTING = \\\n"+pp.pformat(game_index_listing)
+ )
+ saves=True
+ else:
+ game_index_save_text="# No Game Index settings found."
+
+ # potentially add other wizards in the future
+ text=game_index_save_text
+
+ wizard.display(f"Settings to save:\n\n{text}")
+
+ ifsaves:
+ ifwizard.ask_yesno("\nDo you want to save these settings?")=="yes":
+ wizard.save_output=text
+ _save_changes(wizard)
+ wizard.display("... saved!\nThe changes will apply after you reload your server.")
+ else:
+ wizard.display("... cancelled.")
+ wizard.ask_continue()
+ node_start(wizard)
+"""
+This module contains historical deprecations that the Evennia launcher
+checks for.
+
+These all print to the terminal.
+"""
+
+
+
[docs]defcheck_errors(settings):
+ """
+ Check for deprecations that are critical errors and should stop
+ the launcher.
+
+ Args:
+ settings (Settings): The Django settings file
+
+ Raises:
+ DeprecationWarning if a critical deprecation is found.
+
+ """
+ deprstring=(
+ "settings.%s should be renamed to %s. If defaults are used, "
+ "their path/classname must be updated "
+ "(see evennia/settings_default.py)."
+ )
+ ifhasattr(settings,"CMDSET_DEFAULT"):
+ raiseDeprecationWarning(deprstring%("CMDSET_DEFAULT","CMDSET_CHARACTER"))
+ ifhasattr(settings,"CMDSET_OOC"):
+ raiseDeprecationWarning(deprstring%("CMDSET_OOC","CMDSET_ACCOUNT"))
+ ifsettings.WEBSERVER_ENABLEDandnotisinstance(settings.WEBSERVER_PORTS[0],tuple):
+ raiseDeprecationWarning(
+ "settings.WEBSERVER_PORTS must be on the form ""[(proxyport, serverport), ...]"
+ )
+ ifhasattr(settings,"BASE_COMM_TYPECLASS"):
+ raiseDeprecationWarning(deprstring%("BASE_COMM_TYPECLASS","BASE_CHANNEL_TYPECLASS"))
+ ifhasattr(settings,"COMM_TYPECLASS_PATHS"):
+ raiseDeprecationWarning(deprstring%("COMM_TYPECLASS_PATHS","CHANNEL_TYPECLASS_PATHS"))
+ ifhasattr(settings,"CHARACTER_DEFAULT_HOME"):
+ raiseDeprecationWarning(
+ "settings.CHARACTER_DEFAULT_HOME should be renamed to "
+ "DEFAULT_HOME. See also settings.START_LOCATION "
+ "(see evennia/settings_default.py)."
+ )
+ deprstring=(
+ "settings.%s is now merged into settings.TYPECLASS_PATHS. ""Update your settings file."
+ )
+ ifhasattr(settings,"OBJECT_TYPECLASS_PATHS"):
+ raiseDeprecationWarning(deprstring%"OBJECT_TYPECLASS_PATHS")
+ ifhasattr(settings,"SCRIPT_TYPECLASS_PATHS"):
+ raiseDeprecationWarning(deprstring%"SCRIPT_TYPECLASS_PATHS")
+ ifhasattr(settings,"ACCOUNT_TYPECLASS_PATHS"):
+ raiseDeprecationWarning(deprstring%"ACCOUNT_TYPECLASS_PATHS")
+ ifhasattr(settings,"CHANNEL_TYPECLASS_PATHS"):
+ raiseDeprecationWarning(deprstring%"CHANNEL_TYPECLASS_PATHS")
+ ifhasattr(settings,"SEARCH_MULTIMATCH_SEPARATOR"):
+ raiseDeprecationWarning(
+ "settings.SEARCH_MULTIMATCH_SEPARATOR was replaced by "
+ "SEARCH_MULTIMATCH_REGEX and SEARCH_MULTIMATCH_TEMPLATE. "
+ "Update your settings file (see evennia/settings_default.py "
+ "for more info)."
+ )
+
+ gametime_deprecation=(
+ "The settings TIME_SEC_PER_MIN, TIME_MIN_PER_HOUR,"
+ "TIME_HOUR_PER_DAY, TIME_DAY_PER_WEEK, \n"
+ "TIME_WEEK_PER_MONTH and TIME_MONTH_PER_YEAR "
+ "are no longer supported. Remove them from your "
+ "settings file to continue.\nIf you want to use "
+ "and manipulate these time units, the tools from utils.gametime "
+ "are now found in contrib/convert_gametime.py instead."
+ )
+ ifany(
+ hasattr(settings,value)
+ forvaluein(
+ "TIME_SEC_PER_MIN",
+ "TIME_MIN_PER_HOUR",
+ "TIME_HOUR_PER_DAY",
+ "TIME_DAY_PER_WEEK",
+ "TIME_WEEK_PER_MONTH",
+ "TIME_MONTH_PER_YEAR",
+ )
+ ):
+ raiseDeprecationWarning(gametime_deprecation)
+
+ game_directory_deprecation=(
+ "The setting GAME_DIRECTORY_LISTING was removed. It must be "
+ "renamed to GAME_INDEX_LISTING instead."
+ )
+ ifhasattr(settings,"GAME_DIRECTORY_LISTING"):
+ raiseDeprecationWarning(game_directory_deprecation)
+
+ chan_connectinfo=settings.CHANNEL_CONNECTINFO
+ ifchan_connectinfoisnotNoneandnotisinstance(chan_connectinfo,dict):
+ raiseDeprecationWarning(
+ "settings.CHANNEL_CONNECTINFO has changed. It "
+ "must now be either None or a dict "
+ "specifying the properties of the channel to create."
+ )
+ ifhasattr(settings,"CYCLE_LOGFILES"):
+ raiseDeprecationWarning(
+ "settings.CYCLE_LOGFILES is unused and should be removed. "
+ "Use PORTAL/SERVER_LOG_DAY_ROTATION and PORTAL/SERVER_LOG_MAX_SIZE "
+ "to control log cycling."
+ )
+
+
+
[docs]defcheck_warnings(settings):
+ """
+ Check conditions and deprecations that should produce warnings but which
+ does not stop launch.
+ """
+ ifsettings.DEBUG:
+ print(" [Devel: settings.DEBUG is True. Important to turn off in production.]")
+ ifsettings.IN_GAME_ERRORS:
+ print(" [Devel: settings.IN_GAME_ERRORS is True. Turn off in production.]")
+ ifsettings.ALLOWED_HOSTS==["*"]:
+ print(" [Devel: settings.ALLOWED_HOSTS set to '*' (all). Limit in production.]")
+ fordbentryinsettings.DATABASES.values():
+ if"psycopg"indbentry.get("ENGINE",""):
+ print(
+ 'Deprecation: postgresql_psycopg2 backend is deprecated". '
+ "Switch settings.DATABASES to use "
+ '"ENGINE": "django.db.backends.postgresql instead"'
+ )
+#!/usr/bin/python
+"""
+Evennia launcher program
+
+This is the start point for running Evennia.
+
+Sets the appropriate environmental variables for managing an Evennia game. It will start and connect
+to the Portal, through which the Server is also controlled. This pprogram
+
+Run the script with the -h flag to see usage information.
+
+"""
+
+importos
+importsys
+importre
+importsignal
+importshutil
+importimportlib
+importpickle
+fromdistutils.versionimportLooseVersion
+fromargparseimportArgumentParser
+importargparse
+fromsubprocessimportPopen,check_output,call,CalledProcessError,STDOUT
+
+fromtwisted.protocolsimportamp
+fromtwisted.internetimportreactor,endpoints
+importdjango
+fromdjango.core.managementimportexecute_from_command_line
+fromdjango.db.utilsimportProgrammingError
+
+# Signal processing
+SIG=signal.SIGINT
+CTRL_C_EVENT=0# Windows SIGINT-like signal
+
+# Set up the main python paths to Evennia
+EVENNIA_ROOT=os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+importevennia# noqa
+
+EVENNIA_LIB=os.path.join(os.path.dirname(os.path.abspath(evennia.__file__)))
+EVENNIA_SERVER=os.path.join(EVENNIA_LIB,"server")
+EVENNIA_TEMPLATE=os.path.join(EVENNIA_LIB,"game_template")
+EVENNIA_PROFILING=os.path.join(EVENNIA_SERVER,"profiling")
+EVENNIA_DUMMYRUNNER=os.path.join(EVENNIA_PROFILING,"dummyrunner.py")
+
+TWISTED_BINARY="twistd"
+
+# Game directory structure
+SETTINGFILE="settings.py"
+SERVERDIR="server"
+CONFDIR=os.path.join(SERVERDIR,"conf")
+SETTINGS_PATH=os.path.join(CONFDIR,SETTINGFILE)
+SETTINGS_DOTPATH="server.conf.settings"
+CURRENT_DIR=os.getcwd()
+GAMEDIR=CURRENT_DIR
+
+# Operational setup
+
+SERVER_LOGFILE=None
+PORTAL_LOGFILE=None
+HTTP_LOGFILE=None
+
+SERVER_PIDFILE=None
+PORTAL_PIDFILE=None
+
+SERVER_PY_FILE=None
+PORTAL_PY_FILE=None
+
+SPROFILER_LOGFILE=None
+PPROFILER_LOGFILE=None
+
+TEST_MODE=False
+ENFORCED_SETTING=False
+
+REACTOR_RUN=False
+NO_REACTOR_STOP=False
+
+# communication constants
+
+AMP_PORT=None
+AMP_HOST=None
+AMP_INTERFACE=None
+AMP_CONNECTION=None
+
+SRELOAD=chr(14)# server reloading (have portal start a new server)
+SSTART=chr(15)# server start
+PSHUTD=chr(16)# portal (+server) shutdown
+SSHUTD=chr(17)# server-only shutdown
+PSTATUS=chr(18)# ping server or portal status
+SRESET=chr(19)# shutdown server in reset mode
+
+# requirements
+PYTHON_MIN="3.7"
+TWISTED_MIN="18.0.0"
+DJANGO_MIN="3.2"
+DJANGO_LT="3.3"
+
+try:
+ sys.path[1]=EVENNIA_ROOT
+exceptIndexError:
+ sys.path.append(EVENNIA_ROOT)
+
+# ------------------------------------------------------------
+#
+# Messages
+#
+# ------------------------------------------------------------
+
+CREATED_NEW_GAMEDIR="""
+ Welcome to Evennia!
+ Created a new Evennia game directory '{gamedir}'.
+
+ You can now optionally edit your new settings file
+ at {settings_path}. If you don't, the defaults
+ will work out of the box. When ready to continue, 'cd' to your
+ game directory and run:
+
+ evennia migrate
+
+ This initializes the database. To start the server for the first
+ time, run:
+
+ evennia start
+
+ Make sure to create a superuser when asked for it (the email is optional)
+ You should now be able to connect to your server on 'localhost', port 4000
+ using a telnet/mud client or http://localhost:4001 using your web browser.
+ If things don't work, check the log with `evennia --log`. Also make sure
+ ports are open.
+
+ (Finally, why not run `evennia connections` and make the world aware of
+ your new Evennia project!)
+ """
+
+ERROR_INPUT="""
+ Command
+{args}{kwargs}
+ raised an error: '{traceback}'.
+"""
+
+ERROR_NO_ALT_GAMEDIR="""
+ The path '{gamedir}' could not be found.
+"""
+
+ERROR_NO_GAMEDIR="""
+ ERROR: No Evennia settings file was found. Evennia looks for the
+ file in your game directory as ./server/conf/settings.py.
+
+ You must run this command from somewhere inside a valid game
+ directory first created with
+
+ evennia --init mygamename
+
+ If you are in a game directory but is missing a settings.py file,
+ it may be because you have git-cloned an existing game directory.
+ The settings.py file is not cloned by git (it's in .gitignore)
+ since it can contain sensitive and/or server-specific information.
+ You can create a new, empty settings file with
+
+ evennia --initsettings
+
+ If cloning the settings file is not a problem you could manually
+ copy over the old settings file or remove its entry in .gitignore
+
+ """
+
+WARNING_MOVING_SUPERUSER="""
+ WARNING: Evennia expects an Account superuser with id=1. No such
+ Account was found. However, another superuser ('{other_key}',
+ id={other_id}) was found in the database. If you just created this
+ superuser and still see this text it is probably due to the
+ database being flushed recently - in this case the database's
+ internal auto-counter might just start from some value higher than
+ one.
+
+ We will fix this by assigning the id 1 to Account '{other_key}'.
+ Please confirm this is acceptable before continuing.
+ """
+
+WARNING_RUNSERVER="""
+ WARNING: There is no need to run the Django development
+ webserver to test out Evennia web features (the web client
+ will in fact not work since the Django test server knows
+ nothing about MUDs). Instead, just start Evennia with the
+ webserver component active (this is the default).
+ """
+
+ERROR_SETTINGS="""
+ ERROR: There was an error importing Evennia's config file
+{settingspath}.
+ There is usually one of three reasons for this:
+ 1) You are not running this command from your game directory.
+ Change directory to your game directory and try again (or
+ create a new game directory using evennia --init <dirname>)
+ 2) The settings file contains a syntax error. If you see a
+ traceback above, review it, resolve the problem and try again.
+ 3) Django is not correctly installed. This usually shows as
+ errors mentioning 'DJANGO_SETTINGS_MODULE'. If you run a
+ virtual machine, it might be worth to restart it to see if
+ this resolves the issue.
+ """.format(
+ settingspath=SETTINGS_PATH
+)
+
+ERROR_INITSETTINGS="""
+ ERROR: 'evennia --initsettings' must be called from the root of
+ your game directory, since it tries to (re)create the new
+ settings.py file in a subfolder server/conf/.
+ """
+
+RECREATED_SETTINGS="""
+ (Re)created an empty settings file in server/conf/settings.py.
+
+ Note that if you were using an existing database, the password
+ salt of this new settings file will be different from the old one.
+ This means that any existing accounts may not be able to log in to
+ their accounts with their old passwords.
+ """
+
+ERROR_INITMISSING="""
+ ERROR: 'evennia --initmissing' must be called from the root of
+ your game directory, since it tries to create any missing files
+ in the server/ subfolder.
+ """
+
+RECREATED_MISSING="""
+ (Re)created any missing directories or files. Evennia should
+ be ready to run now!
+ """
+
+ERROR_DATABASE="""
+ ERROR: Your database does not exist or is not set up correctly.
+ (error was '{traceback}')
+
+ If you think your database should work, make sure you are running your
+ commands from inside your game directory. If this error persists, run
+
+ evennia migrate
+
+ to initialize/update the database according to your settings.
+ """
+
+ERROR_WINDOWS_WIN32API="""
+ ERROR: Unable to import win32api, which Twisted requires to run.
+ You may download it from:
+
+ http://sourceforge.net/projects/pywin32/files/pywin32/
+
+ If you are running in a virtual environment, browse to the
+ location of the latest win32api exe file for your computer and
+ Python version and copy the url to it; then paste it into a call
+ to easy_install:
+
+ easy_install http://<url to win32api exe>
+ """
+
+INFO_WINDOWS_BATFILE="""
+ INFO: Since you are running Windows, a file 'twistd.bat' was
+ created for you. This is a simple batch file that tries to call
+ the twisted executable. Evennia determined this to be:
+
+{twistd_path}
+
+ If you run into errors at startup you might need to edit
+ twistd.bat to point to the actual location of the Twisted
+ executable (usually called twistd.py) on your machine.
+
+ This procedure is only done once. Run `evennia` again when you
+ are ready to start the server.
+ """
+
+CMDLINE_HELP="""Starts, initializes, manages and operates the Evennia MU* server.
+Most standard django management commands are also accepted."""
+
+
+VERSION_INFO="""
+ Evennia {version}
+ OS: {os}
+ Python: {python}
+ Twisted: {twisted}
+ Django: {django}{about}
+ """
+
+ABOUT_INFO="""
+ Evennia MUD/MUX/MU* development system
+
+ Licence: BSD 3-Clause Licence
+ Web: http://www.evennia.com
+ Irc: #evennia on FreeNode
+ Forum: http://www.evennia.com/discussions
+ Maintainer (2006-10): Greg Taylor
+ Maintainer (2010-): Griatch (griatch AT gmail DOT com)
+
+ Use -h for command line options.
+ """
+
+HELP_ENTRY="""
+ Evennia has two processes, the 'Server' and the 'Portal'.
+ External users connect to the Portal while the Server runs the
+ game/database. Restarting the Server will refresh code but not
+ disconnect users.
+
+ To start a new game, use 'evennia --init mygame'.
+ For more ways to operate and manage Evennia, see 'evennia -h'.
+
+ If you want to add unit tests to your game, see
+ https://github.com/evennia/evennia/wiki/Unit-Testing
+
+ Evennia's manual is found here:
+ https://github.com/evennia/evennia/wiki
+ """
+
+MENU="""
+ +----Evennia Launcher-------------------------------------------+
+{gameinfo}
+ +--- Common operations -----------------------------------------+
+ | 1) Start (also restart stopped Server) |
+ | 2) Reload (stop/start Server in 'reload' mode) |
+ | 3) Stop (shutdown Portal and Server) |
+ | 4) Reboot (shutdown then restart) |
+ +--- Other operations ------------------------------------------+
+ | 5) Reset (stop/start Server in 'shutdown' mode) |
+ | 6) Stop Server only |
+ | 7) Kill Server only (send kill signal to process) |
+ | 8) Kill Portal + Server |
+ +--- Information -----------------------------------------------+
+ | 9) Tail log files (quickly see errors - Ctrl-C to exit) |
+ | 10) Status |
+ | 11) Port info |
+ +--- Testing ---------------------------------------------------+
+ | 12) Test gamedir (run gamedir test suite, if any) |
+ | 13) Test Evennia (run Evennia test suite) |
+ +---------------------------------------------------------------+
+ | h) Help i) About info q) Abort |
+ +---------------------------------------------------------------+"""
+
+ERROR_AMP_UNCONFIGURED="""
+ Can't find server info for connecting. Either run this command from
+ the game dir (it will then use the game's settings file) or specify
+ the path to your game's settings file manually with the --settings
+ option.
+ """
+
+ERROR_LOGDIR_MISSING="""
+ ERROR: One or more log-file directory locations could not be
+ found:
+
+{logfiles}
+
+ This is simple to fix: Just manually create the missing log
+ directory (or directories) and re-launch the server (the log files
+ will be created automatically).
+
+ (Explanation: Evennia creates the log directory automatically when
+ initializing a new game directory. This error usually happens if
+ you used git to clone a pre-created game directory - since log
+ files are in .gitignore they will not be cloned, which leads to
+ the log directory also not being created.)
+ """
+
+ERROR_PYTHON_VERSION="""
+ ERROR: Python {pversion} used. Evennia requires version
+{python_min} or higher.
+ """
+
+ERROR_TWISTED_VERSION="""
+ ERROR: Twisted {tversion} found. Evennia requires
+ version {twisted_min} or higher.
+ """
+
+ERROR_NOTWISTED="""
+ ERROR: Twisted does not seem to be installed.
+ """
+
+ERROR_DJANGO_MIN="""
+ ERROR: Django {dversion} found. Evennia requires at least version {django_min} (but
+ no higher than {django_lt}).
+
+ If you are using a virtualenv, use the command `pip install --upgrade -e evennia` where
+ `evennia` is the folder to where you cloned the Evennia library. If not
+ in a virtualenv you can install django with for example `pip install --upgrade django`
+ or with `pip install django=={django_min}` to get a specific version.
+
+ It's also a good idea to run `evennia migrate` after this upgrade. Ignore
+ any warnings and don't run `makemigrate` even if told to.
+ """
+
+NOTE_DJANGO_NEW="""
+ NOTE: Django {dversion} found. This is newer than Evennia's
+ recommended version ({django_rec}). It might work, but is new
+ enough to not be fully tested yet. Report any issues.
+ """
+
+ERROR_NODJANGO="""
+ ERROR: Django does not seem to be installed.
+ """
+
+NOTE_KEYBOARDINTERRUPT="""
+ STOP: Caught keyboard interrupt while in interactive mode.
+ """
+
+NOTE_TEST_DEFAULT="""
+ TESTING: Using Evennia's default settings file (evennia.settings_default).
+ (use 'evennia test --settings settings.py .' to run only your custom game tests)
+ """
+
+NOTE_TEST_CUSTOM="""
+ TESTING: Using specified settings file '{settings_dotpath}'.
+
+ OBS: Evennia's full test suite may not pass if the settings are very
+ different from the default (use 'evennia test evennia' to run core tests)
+ """
+
+PROCESS_ERROR="""
+{component} process error: {traceback}.
+ """
+
+PORTAL_INFO="""{servername} Portal {version}
+ external ports:
+{telnet}
+{telnet_ssl}
+{ssh}
+{webserver_proxy}
+{webclient}
+ internal_ports (to Server):
+{webserver_internal}
+{amp}
+"""
+
+
+SERVER_INFO="""{servername} Server {version}
+ internal ports (to Portal):
+{webserver}
+{amp}
+{irc_rss}
+{info}
+{errors}"""
+
+
+ARG_OPTIONS="""Actions on installed server. One of:
+ start - launch server+portal if not running
+ reload - restart server in 'reload' mode
+ stop - shutdown server+portal
+ reboot - shutdown server+portal, then start again
+ reset - restart server in 'shutdown' mode
+ istart - start server in foreground (until reload)
+ ipstart - start portal in foreground
+ sstop - stop only server
+ kill - send kill signal to portal+server (force)
+ skill - send kill signal only to server
+ status - show server and portal run state
+ info - show server and portal port info
+ menu - show a menu of options
+ connections - show connection wizard
+Others, like migrate, test and shell is passed on to Django."""
+
+# ------------------------------------------------------------
+#
+# Private helper functions
+#
+# ------------------------------------------------------------
+
+
+def_is_windows():
+ returnos.name=="nt"
+
+
+def_file_names_compact(filepath1,filepath2):
+ "Compact the output of filenames with same base dir"
+ dirname1=os.path.dirname(filepath1)
+ dirname2=os.path.dirname(filepath2)
+ ifdirname1==dirname2:
+ name2=os.path.basename(filepath2)
+ return"{} and {}".format(filepath1,name2)
+ else:
+ return"{} and {}".format(filepath1,filepath2)
+
+
+def_print_info(portal_info_dict,server_info_dict):
+ """
+ Format info dicts from the Portal/Server for display
+
+ """
+ ind=" "*8
+
+ def_prepare_dict(dct):
+ out={}
+ forkey,valueindct.items():
+ ifisinstance(value,list):
+ value="\n{}".format(ind).join(str(val)forvalinvalue)
+ out[key]=value
+ returnout
+
+ def_strip_empty_lines(string):
+ return"\n".join(lineforlineinstring.split("\n")ifline.strip())
+
+ pstr,sstr="",""
+ ifportal_info_dict:
+ pdict=_prepare_dict(portal_info_dict)
+ pstr=_strip_empty_lines(PORTAL_INFO.format(**pdict))
+
+ ifserver_info_dict:
+ sdict=_prepare_dict(server_info_dict)
+ sstr=_strip_empty_lines(SERVER_INFO.format(**sdict))
+
+ info=pstr+("\n\n"+sstrifsstrelse"")
+ maxwidth=max(len(line)forlineininfo.split("\n"))
+ top_border="-"*(maxwidth-11)+" Evennia "+"---"
+ border="-"*(maxwidth+1)
+ print(top_border+"\n"+info+"\n"+border)
+
+
+def_parse_status(response):
+ "Unpack the status information"
+ returnpickle.loads(response["status"])
+
+
+def_get_twistd_cmdline(pprofiler,sprofiler):
+ """
+ Compile the command line for starting a Twisted application using the 'twistd' executable.
+
+ """
+ portal_cmd=[TWISTED_BINARY,"--python={}".format(PORTAL_PY_FILE)]
+ server_cmd=[TWISTED_BINARY,"--python={}".format(SERVER_PY_FILE)]
+
+ ifos.name!="nt":
+ # PID files only for UNIX
+ portal_cmd.append("--pidfile={}".format(PORTAL_PIDFILE))
+ server_cmd.append("--pidfile={}".format(SERVER_PIDFILE))
+
+ ifpprofiler:
+ portal_cmd.extend(
+ ["--savestats","--profiler=cprofile","--profile={}".format(PPROFILER_LOGFILE)]
+ )
+ ifsprofiler:
+ server_cmd.extend(
+ ["--savestats","--profiler=cprofile","--profile={}".format(SPROFILER_LOGFILE)]
+ )
+
+ returnportal_cmd,server_cmd
+
+
+def_reactor_stop():
+ ifnotNO_REACTOR_STOP:
+ reactor.stop()
+
+
+# ------------------------------------------------------------
+#
+# Protocol Evennia launcher - Portal/Server communication
+#
+# ------------------------------------------------------------
+
+
+
[docs]defwait_for_status_reply(callback):
+ """
+ Wait for an explicit STATUS signal to be sent back from Evennia.
+ """
+ ifAMP_CONNECTION:
+ AMP_CONNECTION.wait_for_status(callback)
+ else:
+ print("No Evennia connection established.")
+
+
+
[docs]defwait_for_status(
+ portal_running=True,server_running=True,callback=None,errback=None,rate=0.5,retries=20
+):
+ """
+ Repeat the status ping until the desired state combination is achieved.
+
+ Args:
+ portal_running (bool or None): Desired portal run-state. If None, any state
+ is accepted.
+ server_running (bool or None): Desired server run-state. If None, any state
+ is accepted. The portal must be running.
+ callback (callable): Will be called with portal_state, server_state when
+ condition is fulfilled.
+ errback (callable): Will be called with portal_state, server_state if the
+ request is timed out.
+ rate (float): How often to retry.
+ retries (int): How many times to retry before timing out and calling `errback`.
+ """
+
+ def_callback(response):
+ prun,srun,_,_,_,_=_parse_status(response)
+ if(portal_runningisNoneorprun==portal_running)and(
+ server_runningisNoneorsrun==server_running
+ ):
+ # the correct state was achieved
+ ifcallback:
+ callback(prun,srun)
+ else:
+ _reactor_stop()
+ else:
+ ifretries<=0:
+ iferrback:
+ errback(prun,srun)
+ else:
+ print("Connection to Evennia timed out. Try again.")
+ _reactor_stop()
+ else:
+ reactor.callLater(
+ rate,
+ wait_for_status,
+ portal_running,
+ server_running,
+ callback,
+ errback,
+ rate,
+ retries-1,
+ )
+
+ def_errback(fail):
+ """
+ Portal not running
+ """
+ ifnotportal_running:
+ # this is what we want
+ ifcallback:
+ callback(portal_running,server_running)
+ else:
+ _reactor_stop()
+ else:
+ ifretries<=0:
+ iferrback:
+ errback(portal_running,server_running)
+ else:
+ print("Connection to Evennia timed out. Try again.")
+ _reactor_stop()
+ else:
+ reactor.callLater(
+ rate,
+ wait_for_status,
+ portal_running,
+ server_running,
+ callback,
+ errback,
+ rate,
+ retries-1,
+ )
+
+ returnsend_instruction(PSTATUS,None,_callback,_errback)
[docs]defcollectstatic():
+ "Run the collectstatic django command"
+ django.core.management.call_command("collectstatic",interactive=False,verbosity=0)
+
+
+
[docs]defstart_evennia(pprofiler=False,sprofiler=False):
+ """
+ This will start Evennia anew by launching the Evennia Portal (which in turn
+ will start the Server)
+
+ """
+ portal_cmd,server_cmd=_get_twistd_cmdline(pprofiler,sprofiler)
+
+ def_fail(fail):
+ print(fail)
+ _reactor_stop()
+
+ def_server_started(response):
+ print("... Server started.\nEvennia running.")
+ ifresponse:
+ _,_,_,_,pinfo,sinfo=response
+ _print_info(pinfo,sinfo)
+ _reactor_stop()
+
+ def_portal_started(*args):
+ print(
+ "... Portal started.\nServer starting {} ...".format(
+ "(under cProfile)"ifsprofilerelse""
+ )
+ )
+ wait_for_status_reply(_server_started)
+ send_instruction(SSTART,server_cmd)
+
+ def_portal_running(response):
+ prun,srun,ppid,spid,_,_=_parse_status(response)
+ print("Portal is already running as process {pid}. Not restarted.".format(pid=ppid))
+ ifsrun:
+ print("Server is already running as process {pid}. Not restarted.".format(pid=spid))
+ _reactor_stop()
+ else:
+ print("Server starting {}...".format("(under cProfile)"ifsprofilerelse""))
+ send_instruction(SSTART,server_cmd,_server_started,_fail)
+
+ def_portal_not_running(fail):
+ print("Portal starting {}...".format("(under cProfile)"ifpprofilerelse""))
+ try:
+ if_is_windows():
+ # Windows requires special care
+ create_no_window=0x08000000
+ Popen(portal_cmd,env=getenv(),bufsize=-1,creationflags=create_no_window)
+ else:
+ Popen(portal_cmd,env=getenv(),bufsize=-1)
+ exceptExceptionase:
+ print(PROCESS_ERROR.format(component="Portal",traceback=e))
+ _reactor_stop()
+ wait_for_status(True,None,_portal_started)
+
+ collectstatic()
+ send_instruction(PSTATUS,None,_portal_running,_portal_not_running)
+
+
+
[docs]defreload_evennia(sprofiler=False,reset=False):
+ """
+ This will instruct the Portal to reboot the Server component. We
+ do this manually by telling the server to shutdown (in reload mode)
+ and wait for the portal to report back, at which point we start the
+ server again. This way we control the process exactly.
+
+ """
+ _,server_cmd=_get_twistd_cmdline(False,sprofiler)
+
+ def_server_restarted(*args):
+ print("... Server re-started.")
+ _reactor_stop()
+
+ def_server_reloaded(status):
+ print("... Server {}.".format("reset"ifresetelse"reloaded"))
+ _reactor_stop()
+
+ def_server_stopped(status):
+ wait_for_status_reply(_server_reloaded)
+ send_instruction(SSTART,server_cmd)
+
+ def_portal_running(response):
+ _,srun,_,_,_,_=_parse_status(response)
+ ifsrun:
+ print("Server {}...".format("resetting"ifresetelse"reloading"))
+ wait_for_status_reply(_server_stopped)
+ send_instruction(SRESETifresetelseSRELOAD,{})
+ else:
+ print("Server down. Re-starting ...")
+ wait_for_status_reply(_server_restarted)
+ send_instruction(SSTART,server_cmd)
+
+ def_portal_not_running(fail):
+ print("Evennia not running. Starting up ...")
+ start_evennia()
+
+ collectstatic()
+ send_instruction(PSTATUS,None,_portal_running,_portal_not_running)
+
+
+
[docs]defstop_evennia():
+ """
+ This instructs the Portal to stop the Server and then itself.
+
+ """
+
+ def_portal_stopped(*args):
+ print("... Portal stopped.\nEvennia shut down.")
+ _reactor_stop()
+
+ def_server_stopped(*args):
+ print("... Server stopped.\nStopping Portal ...")
+ send_instruction(PSHUTD,{})
+ wait_for_status(False,None,_portal_stopped)
+
+ def_portal_running(response):
+ prun,srun,ppid,spid,_,_=_parse_status(response)
+ ifsrun:
+ print("Server stopping ...")
+ send_instruction(SSHUTD,{})
+ wait_for_status_reply(_server_stopped)
+ else:
+ print("Server already stopped.\nStopping Portal ...")
+ send_instruction(PSHUTD,{})
+ wait_for_status(False,None,_portal_stopped)
+
+ def_portal_not_running(fail):
+ print("Evennia not running.")
+ _reactor_stop()
+
+ send_instruction(PSTATUS,None,_portal_running,_portal_not_running)
+
+
+
[docs]defreboot_evennia(pprofiler=False,sprofiler=False):
+ """
+ This is essentially an evennia stop && evennia start except we make sure
+ the system has successfully shut down before starting it again.
+
+ If evennia was not running, start it.
+
+ """
+ globalAMP_CONNECTION
+
+ def_portal_stopped(*args):
+ print("... Portal stopped. Evennia shut down. Rebooting ...")
+ globalAMP_CONNECTION
+ AMP_CONNECTION=None
+ start_evennia(pprofiler,sprofiler)
+
+ def_server_stopped(*args):
+ print("... Server stopped.\nStopping Portal ...")
+ send_instruction(PSHUTD,{})
+ wait_for_status(False,None,_portal_stopped)
+
+ def_portal_running(response):
+ prun,srun,ppid,spid,_,_=_parse_status(response)
+ ifsrun:
+ print("Server stopping ...")
+ send_instruction(SSHUTD,{})
+ wait_for_status_reply(_server_stopped)
+ else:
+ print("Server already stopped.\nStopping Portal ...")
+ send_instruction(PSHUTD,{})
+ wait_for_status(False,None,_portal_stopped)
+
+ def_portal_not_running(fail):
+ print("Evennia not running. Starting up ...")
+ start_evennia()
+
+ collectstatic()
+ send_instruction(PSTATUS,None,_portal_running,_portal_not_running)
+
+
+
[docs]defstart_only_server():
+ """
+ Tell portal to start server (debug)
+ """
+ portal_cmd,server_cmd=_get_twistd_cmdline(False,False)
+ print("launcher: Sending to portal: SSTART + {}".format(server_cmd))
+ collectstatic()
+ send_instruction(SSTART,server_cmd)
+
+
+
[docs]defstart_server_interactive():
+ """
+ Start the Server under control of the launcher process (foreground)
+
+ """
+
+ def_iserver():
+ _,server_twistd_cmd=_get_twistd_cmdline(False,False)
+ server_twistd_cmd.append("--nodaemon")
+ print("Starting Server in interactive mode (stop with Ctrl-C)...")
+ try:
+ Popen(server_twistd_cmd,env=getenv(),stderr=STDOUT).wait()
+ exceptKeyboardInterrupt:
+ print("... Stopped Server with Ctrl-C.")
+ else:
+ print("... Server stopped (leaving interactive mode).")
+
+ collectstatic()
+ stop_server_only(when_stopped=_iserver,interactive=True)
+
+
+
[docs]defstart_portal_interactive():
+ """
+ Start the Portal under control of the launcher process (foreground)
+
+ Notes:
+ In a normal start, the launcher waits for the Portal to start, then
+ tells it to start the Server. Since we can't do this here, we instead
+ start the Server first and then starts the Portal - the Server will
+ auto-reconnect to the Portal. To allow the Server to be reloaded, this
+ relies on a fixed server server-cmdline stored as a fallback on the
+ portal application in evennia/server/portal/portal.py.
+
+ """
+
+ def_iportal(fail):
+ portal_twistd_cmd,server_twistd_cmd=_get_twistd_cmdline(False,False)
+ portal_twistd_cmd.append("--nodaemon")
+
+ # starting Server first - it will auto-connect once Portal comes up
+ if_is_windows():
+ # Windows requires special care
+ create_no_window=0x08000000
+ Popen(server_twistd_cmd,env=getenv(),bufsize=-1,creationflags=create_no_window)
+ else:
+ Popen(server_twistd_cmd,env=getenv(),bufsize=-1)
+
+ print("Starting Portal in interactive mode (stop with Ctrl-C)...")
+ try:
+ Popen(portal_twistd_cmd,env=getenv(),stderr=STDOUT).wait()
+ exceptKeyboardInterrupt:
+ print("... Stopped Portal with Ctrl-C.")
+ else:
+ print("... Portal stopped (leaving interactive mode).")
+
+ def_portal_running(response):
+ print("Evennia must be shut down completely before running Portal in interactive mode.")
+ _reactor_stop()
+
+ send_instruction(PSTATUS,None,_portal_running,_iportal)
+
+
+
[docs]defstop_server_only(when_stopped=None,interactive=False):
+ """
+ Only stop the Server-component of Evennia (this is not useful except for debug)
+
+ Args:
+ when_stopped (callable): This will be called with no arguments when Server has stopped (or
+ if it had already stopped when this is called).
+ interactive (bool, optional): Set if this is called as part of the interactive reload
+ mechanism.
+
+ """
+
+ def_server_stopped(*args):
+ ifwhen_stopped:
+ when_stopped()
+ else:
+ print("... Server stopped.")
+ _reactor_stop()
+
+ def_portal_running(response):
+ _,srun,_,_,_,_=_parse_status(response)
+ ifsrun:
+ print("Server stopping ...")
+ wait_for_status_reply(_server_stopped)
+ ifinteractive:
+ send_instruction(SRELOAD,{})
+ else:
+ send_instruction(SSHUTD,{})
+ else:
+ ifwhen_stopped:
+ when_stopped()
+ else:
+ print("Server is not running.")
+ _reactor_stop()
+
+ def_portal_not_running(fail):
+ print("Evennia is not running.")
+ ifinteractive:
+ print("Start Evennia normally first, then use `istart` to switch to interactive mode.")
+ _reactor_stop()
+
+ send_instruction(PSTATUS,None,_portal_running,_portal_not_running)
+
+
+
[docs]defquery_info():
+ """
+ Display the info strings from the running Evennia
+
+ """
+
+ def_got_status(status):
+ _,_,_,_,pinfo,sinfo=_parse_status(status)
+ _print_info(pinfo,sinfo)
+ _reactor_stop()
+
+ def_portal_running(response):
+ query_status(_got_status)
+
+ def_portal_not_running(fail):
+ print("Evennia is not running.")
+
+ send_instruction(PSTATUS,None,_portal_running,_portal_not_running)
+
+
+
[docs]deftail_log_files(filename1,filename2,start_lines1=20,start_lines2=20,rate=1):
+ """
+ Tail two logfiles interactively, combining their output to stdout
+
+ When first starting, this will display the tail of the log files. After
+ that it will poll the log files repeatedly and display changes.
+
+ Args:
+ filename1 (str): Path to first log file.
+ filename2 (str): Path to second log file.
+ start_lines1 (int): How many lines to show from existing first log.
+ start_lines2 (int): How many lines to show from existing second log.
+ rate (int, optional): How often to poll the log file.
+
+ """
+ globalREACTOR_RUN
+
+ def_file_changed(filename,prev_size):
+ "Get size of file in bytes, get diff compared with previous size"
+ try:
+ new_size=os.path.getsize(filename)
+ exceptFileNotFoundError:
+ returnFalse,0
+ returnnew_size!=prev_size,new_size
+
+ def_get_new_lines(filehandle,old_linecount):
+ "count lines, get the ones not counted before"
+
+ def_block(filehandle,size=65536):
+ "File block generator for quick traversal"
+ whileTrue:
+ dat=filehandle.read(size)
+ ifnotdat:
+ break
+ yielddat
+
+ # count number of lines in file
+ new_linecount=sum(blck.count("\n")forblckin_block(filehandle))
+
+ ifnew_linecount<old_linecount:
+ # this happens if the file was cycled or manually deleted/edited.
+ print(
+ " ** Log file {filename} has cycled or been edited. "
+ "Restarting log. ".format(filename=filehandle.name)
+ )
+ new_linecount=0
+ old_linecount=0
+
+ lines_to_get=max(0,new_linecount-old_linecount)
+
+ ifnotlines_to_get:
+ return[],old_linecount
+
+ lines_found=[]
+ buffer_size=4098
+ block_count=-1
+
+ whilelen(lines_found)<lines_to_get:
+ try:
+ # scan backwards in file, starting from the end
+ filehandle.seek(block_count*buffer_size,os.SEEK_END)
+ exceptIOError:
+ # file too small for current seek, include entire file
+ filehandle.seek(0)
+ lines_found=filehandle.readlines()
+ break
+ lines_found=filehandle.readlines()
+ block_count-=1
+
+ # only actually return the new lines
+ returnlines_found[-lines_to_get:],new_linecount
+
+ def_tail_file(filename,file_size,line_count,max_lines=None):
+ """This will cycle repeatedly, printing new lines"""
+
+ # poll for changes
+ has_changed,file_size=_file_changed(filename,file_size)
+
+ ifhas_changed:
+ try:
+ withopen(filename,"r")asfilehandle:
+ new_lines,line_count=_get_new_lines(filehandle,line_count)
+ exceptIOError:
+ # the log file might not exist yet. Wait a little, then try again ...
+ pass
+ else:
+ ifmax_lines==0:
+ # don't show any lines from old file
+ new_lines=[]
+ elifmax_lines:
+ # show some lines from first startup
+ new_lines=new_lines[-max_lines:]
+
+ # print to stdout without line break (log has its own line feeds)
+ sys.stdout.write("".join(new_lines))
+ sys.stdout.flush()
+
+ # set up the next poll
+ reactor.callLater(rate,_tail_file,filename,file_size,line_count,max_lines=100)
+
+ reactor.callLater(0,_tail_file,filename1,0,0,max_lines=start_lines1)
+ reactor.callLater(0,_tail_file,filename2,0,0,max_lines=start_lines2)
+
+ REACTOR_RUN=True
[docs]defevennia_version():
+ """
+ Get the Evennia version info from the main package.
+
+ """
+ version="Unknown"
+ try:
+ version=evennia.__version__
+ exceptImportError:
+ # even if evennia is not found, we should not crash here.
+ pass
+ try:
+ rev=(
+ check_output("git rev-parse --short HEAD",shell=True,cwd=EVENNIA_ROOT,stderr=STDOUT)
+ .strip()
+ .decode()
+ )
+ version="%s (rev %s)"%(version,rev)
+ except(IOError,CalledProcessError,OSError):
+ # move on if git is not answering
+ pass
+ returnversion
+
+
+EVENNIA_VERSION=evennia_version()
+
+
+
[docs]defcheck_main_evennia_dependencies():
+ """
+ Checks and imports the Evennia dependencies. This must be done
+ already before the paths are set up.
+
+ Returns:
+ not_error (bool): True if no dependency error was found.
+
+ """
+ error=False
+
+ # Python
+ pversion=".".join(str(num)fornuminsys.version_infoifisinstance(num,int))
+ ifLooseVersion(pversion)<LooseVersion(PYTHON_MIN):
+ print(ERROR_PYTHON_VERSION.format(pversion=pversion,python_min=PYTHON_MIN))
+ error=True
+ # Twisted
+ try:
+ importtwisted
+
+ tversion=twisted.version.short()
+ ifLooseVersion(tversion)<LooseVersion(TWISTED_MIN):
+ print(ERROR_TWISTED_VERSION.format(tversion=tversion,twisted_min=TWISTED_MIN))
+ error=True
+ exceptImportError:
+ print(ERROR_NOTWISTED)
+ error=True
+ # Django
+ try:
+ dversion=".".join(str(num)fornumindjango.VERSIONifisinstance(num,int))
+ # only the main version (1.5, not 1.5.4.0)
+ dversion_main=".".join(dversion.split(".")[:2])
+ ifLooseVersion(dversion)<LooseVersion(DJANGO_MIN):
+ print(
+ ERROR_DJANGO_MIN.format(
+ dversion=dversion_main,django_min=DJANGO_MIN,django_lt=DJANGO_LT
+ )
+ )
+ error=True
+ elifLooseVersion(DJANGO_LT)<=LooseVersion(dversion_main):
+ print(NOTE_DJANGO_NEW.format(dversion=dversion_main,django_rec=DJANGO_LT))
+ exceptImportError:
+ print(ERROR_NODJANGO)
+ error=True
+ iferror:
+ sys.exit()
+
+ # return True/False if error was reported or not
+ returnnoterror
+
+
+
[docs]defset_gamedir(path):
+ """
+ Set GAMEDIR based on path, by figuring out where the setting file
+ is inside the directory tree. This allows for running the launcher
+ from elsewhere than the top of the gamedir folder.
+
+ """
+ globalGAMEDIR
+
+ Ndepth=10
+ settings_path=os.path.join("server","conf","settings.py")
+ os.chdir(GAMEDIR)
+ foriinrange(Ndepth):
+ gpath=os.getcwd()
+ if"server"inos.listdir(gpath):
+ ifos.path.isfile(settings_path):
+ GAMEDIR=gpath
+ return
+ os.chdir(os.pardir)
+ print(ERROR_NO_GAMEDIR)
+ sys.exit()
[docs]defcreate_settings_file(init=True,secret_settings=False):
+ """
+ Uses the template settings file to build a working settings file.
+
+ Args:
+ init (bool): This is part of the normal evennia --init
+ operation. If false, this function will copy a fresh
+ template file in (asking if it already exists).
+ secret_settings (bool, optional): If False, create settings.py, otherwise
+ create the secret_settings.py file.
+
+ """
+ ifsecret_settings:
+ settings_path=os.path.join(GAMEDIR,"server","conf","secret_settings.py")
+ setting_dict={"secret_key":"'%s'"%create_secret_key()}
+ else:
+ settings_path=os.path.join(GAMEDIR,"server","conf","settings.py")
+ setting_dict={
+ "settings_default":os.path.join(EVENNIA_LIB,"settings_default.py"),
+ "servername":'"%s"'%GAMEDIR.rsplit(os.path.sep,1)[1],
+ "secret_key":"'%s'"%create_secret_key(),
+ }
+
+ ifnotinit:
+ # if not --init mode, settings file may already exist from before
+ ifos.path.exists(settings_path):
+ inp=input("%s already exists. Do you want to reset it? y/[N]> "%settings_path)
+ ifnotinp.lower()=="y":
+ print("Aborted.")
+ sys.exit()
+ else:
+ print("Reset the settings file.")
+
+ ifsecret_settings:
+ default_settings_path=os.path.join(
+ EVENNIA_TEMPLATE,"server","conf","secret_settings.py"
+ )
+ else:
+ default_settings_path=os.path.join(EVENNIA_TEMPLATE,"server","conf","settings.py")
+ shutil.copy(default_settings_path,settings_path)
+
+ withopen(settings_path,"r")asf:
+ settings_string=f.read()
+
+ settings_string=settings_string.format(**setting_dict)
+
+ withopen(settings_path,"w")asf:
+ f.write(settings_string)
+
+
+
[docs]defcreate_game_directory(dirname):
+ """
+ Initialize a new game directory named dirname
+ at the current path. This means copying the
+ template directory from evennia's root.
+
+ Args:
+ dirname (str): The directory name to create.
+
+ """
+ globalGAMEDIR
+ GAMEDIR=os.path.abspath(os.path.join(CURRENT_DIR,dirname))
+ ifos.path.exists(GAMEDIR):
+ print("Cannot create new Evennia game dir: '%s' already exists."%dirname)
+ sys.exit()
+ # copy template directory
+ shutil.copytree(EVENNIA_TEMPLATE,GAMEDIR)
+ # rename gitignore to .gitignore
+ os.rename(os.path.join(GAMEDIR,"gitignore"),os.path.join(GAMEDIR,".gitignore"))
+
+ # pre-build settings file in the new GAMEDIR
+ create_settings_file()
+ create_settings_file(secret_settings=True)
+
+
+
[docs]defcreate_superuser():
+ """
+ Create the superuser account
+
+ """
+ print(
+ "\nCreate a superuser below. The superuser is Account #1, the 'owner' "
+ "account of the server. Email is optional and can be empty.\n"
+ )
+ django.core.management.call_command("createsuperuser",interactive=True)
+
+
+
[docs]defcheck_database(always_return=False):
+ """
+ Check so the database exists.
+
+ Args:
+ always_return (bool, optional): If set, will always return True/False
+ also on critical errors. No output will be printed.
+ Returns:
+ exists (bool): `True` if the database exists, otherwise `False`.
+
+
+ """
+ # Check so a database exists and is accessible
+ fromdjango.dbimportconnection
+
+ tables=connection.introspection.get_table_list(connection.cursor())
+ ifnottablesornotisinstance(tables[0],str):# django 1.8+
+ tables=[tableinfo.namefortableinfointables]
+ iftablesand"accounts_accountdb"intables:
+ # database exists and seems set up. Initialize evennia.
+ evennia._init()
+ # Try to get Account#1
+ fromevennia.accounts.modelsimportAccountDB
+
+ try:
+ AccountDB.objects.get(id=1)
+ except(django.db.utils.OperationalError,ProgrammingError)ase:
+ ifalways_return:
+ returnFalse
+ print(ERROR_DATABASE.format(traceback=e))
+ sys.exit()
+ exceptAccountDB.DoesNotExist:
+ # no superuser yet. We need to create it.
+
+ other_superuser=AccountDB.objects.filter(is_superuser=True)
+ ifother_superuser:
+ # Another superuser was found, but not with id=1. This may
+ # happen if using flush (the auto-id starts at a higher
+ # value). Wwe copy this superuser into id=1. To do
+ # this we must deepcopy it, delete it then save the copy
+ # with the new id. This allows us to avoid the UNIQUE
+ # constraint on usernames.
+ other=other_superuser[0]
+ other_id=other.id
+ other_key=other.username
+ print(WARNING_MOVING_SUPERUSER.format(other_key=other_key,other_id=other_id))
+ res=""
+ whileres.upper()!="Y":
+ # ask for permission
+ res=eval(input("Continue [Y]/N: "))
+ ifres.upper()=="N":
+ sys.exit()
+ elifnotres:
+ break
+ # continue with the
+ fromcopyimportdeepcopy
+
+ new=deepcopy(other)
+ other.delete()
+ new.id=1
+ new.save()
+ else:
+ create_superuser()
+ check_database(always_return=always_return)
+ returnTrue
+
+
+
[docs]defgetenv():
+ """
+ Get current environment and add PYTHONPATH.
+
+ Returns:
+ env (dict): Environment global dict.
+
+ """
+ sep=";"if_is_windows()else":"
+ env=os.environ.copy()
+ env["PYTHONPATH"]=sep.join(sys.path)
+ returnenv
+
+
+
[docs]defget_pid(pidfile,default=None):
+ """
+ Get the PID (Process ID) by trying to access an PID file.
+
+ Args:
+ pidfile (str): The path of the pid file.
+ default (int, optional): What to return if file does not exist.
+
+ Returns:
+ pid (str): The process id or `default`.
+
+ """
+ ifos.path.exists(pidfile):
+ withopen(pidfile,"r")asf:
+ pid=f.read()
+ returnpid
+ returndefault
+
+
+
[docs]defdel_pid(pidfile):
+ """
+ The pidfile should normally be removed after a process has
+ finished, but when sending certain signals they remain, so we need
+ to clean them manually.
+
+ Args:
+ pidfile (str): The path of the pid file.
+
+ """
+ ifos.path.exists(pidfile):
+ os.remove(pidfile)
+
+
+
[docs]defkill(pidfile,component="Server",callback=None,errback=None,killsignal=SIG):
+ """
+ Send a kill signal to a process based on PID. A customized
+ success/error message will be returned. If clean=True, the system
+ will attempt to manually remove the pid file. On Windows, no arguments
+ are useful since Windows has no ability to direct signals except to all
+ children of a console.
+
+ Args:
+ pidfile (str): The path of the pidfile to get the PID from. This is ignored
+ on Windows.
+ component (str, optional): Usually one of 'Server' or 'Portal'. This is
+ ignored on Windows.
+ errback (callable, optional): Called if signal failed to send. This
+ is ignored on Windows.
+ callback (callable, optional): Called if kill signal was sent successfully.
+ This is ignored on Windows.
+ killsignal (int, optional): Signal identifier for signal to send. This is
+ ignored on Windows.
+
+ """
+ if_is_windows():
+ # Windows signal sending is very limited.
+ fromwin32apiimportGenerateConsoleCtrlEvent,SetConsoleCtrlHandler
+
+ try:
+ # Windows can only send a SIGINT-like signal to
+ # *every* process spawned off the same console, so we must
+ # avoid killing ourselves here.
+ SetConsoleCtrlHandler(None,True)
+ GenerateConsoleCtrlEvent(CTRL_C_EVENT,0)
+ exceptKeyboardInterrupt:
+ # We must catch and ignore the interrupt sent.
+ pass
+ print("Sent kill signal to all spawned processes")
+
+ else:
+ # Linux/Unix/Mac can send kill signal directly to specific PIDs.
+ pid=get_pid(pidfile)
+ ifpid:
+ if_is_windows():
+ os.remove(pidfile)
+ try:
+ os.kill(int(pid),killsignal)
+ exceptOSError:
+ print(
+ "{component} ({pid}) cannot be stopped. "
+ "The PID file '{pidfile}' seems stale. "
+ "Try removing it manually.".format(
+ component=component,pid=pid,pidfile=pidfile
+ )
+ )
+ return
+ ifcallback:
+ callback()
+ else:
+ print("Sent kill signal to {component}.".format(component=component))
+ return
+ iferrback:
+ errback()
+ else:
+ print(
+ "Could not send kill signal - {component} does "
+ "not appear to be running.".format(component=component)
+ )
+
+
+
[docs]defshow_version_info(about=False):
+ """
+ Display version info.
+
+ Args:
+ about (bool): Include ABOUT info as well as version numbers.
+
+ Returns:
+ version_info (str): A complete version info string.
+
+ """
+ importsys
+ importtwisted
+
+ returnVERSION_INFO.format(
+ version=EVENNIA_VERSION,
+ about=ABOUT_INFOifaboutelse"",
+ os=os.name,
+ python=sys.version.split()[0],
+ twisted=twisted.version.short(),
+ django=django.get_version(),
+ )
+
+
+
[docs]deferror_check_python_modules(show_warnings=False):
+ """
+ Import settings modules in settings. This will raise exceptions on
+ pure python-syntax issues which are hard to catch gracefully with
+ exceptions in the engine (since they are formatting errors in the
+ python source files themselves). Best they fail already here
+ before we get any further.
+
+ Keyword Args:
+ show_warnings (bool): If non-fatal warning messages should be shown.
+
+ """
+
+ fromdjango.confimportsettings
+
+ def_imp(path,split=True):
+ "helper method"
+ mod,fromlist=path,"None"
+ ifsplit:
+ mod,fromlist=path.rsplit(".",1)
+ __import__(mod,fromlist=[fromlist])
+
+ # check the historical deprecations
+ fromevennia.serverimportdeprecations
+
+ try:
+ deprecations.check_errors(settings)
+ exceptDeprecationWarningaserr:
+ print(err)
+ sys.exit()
+
+ ifshow_warnings:
+ deprecations.check_warnings(settings)
+
+ # core modules
+ _imp(settings.COMMAND_PARSER)
+ _imp(settings.SEARCH_AT_RESULT)
+ _imp(settings.CONNECTION_SCREEN_MODULE)
+ # imp(settings.AT_INITIAL_SETUP_HOOK_MODULE, split=False)
+ forpathinsettings.LOCK_FUNC_MODULES:
+ _imp(path,split=False)
+
+ fromevennia.commandsimportcmdsethandler
+
+ ifnotcmdsethandler.import_cmdset(settings.CMDSET_UNLOGGEDIN,None):
+ print("Warning: CMDSET_UNLOGGED failed to load!")
+ ifnotcmdsethandler.import_cmdset(settings.CMDSET_CHARACTER,None):
+ print("Warning: CMDSET_CHARACTER failed to load")
+ ifnotcmdsethandler.import_cmdset(settings.CMDSET_ACCOUNT,None):
+ print("Warning: CMDSET_ACCOUNT failed to load")
+ # typeclasses
+ _imp(settings.BASE_ACCOUNT_TYPECLASS)
+ _imp(settings.BASE_OBJECT_TYPECLASS)
+ _imp(settings.BASE_CHARACTER_TYPECLASS)
+ _imp(settings.BASE_ROOM_TYPECLASS)
+ _imp(settings.BASE_EXIT_TYPECLASS)
+ _imp(settings.BASE_SCRIPT_TYPECLASS)
[docs]definit_game_directory(path,check_db=True,need_gamedir=True):
+ """
+ Try to analyze the given path to find settings.py - this defines
+ the game directory and also sets PYTHONPATH as well as the django
+ path.
+
+ Args:
+ path (str): Path to new game directory, including its name.
+ check_db (bool, optional): Check if the databae exists.
+ need_gamedir (bool, optional): set to False if Evennia doesn't require to
+ be run in a valid game directory.
+
+ """
+ # set the GAMEDIR path
+ ifneed_gamedir:
+ set_gamedir(path)
+
+ # Add gamedir to python path
+ sys.path.insert(0,GAMEDIR)
+
+ ifTEST_MODEornotneed_gamedir:
+ ifENFORCED_SETTING:
+ print(NOTE_TEST_CUSTOM.format(settings_dotpath=SETTINGS_DOTPATH))
+ os.environ["DJANGO_SETTINGS_MODULE"]=SETTINGS_DOTPATH
+ else:
+ print(NOTE_TEST_DEFAULT)
+ os.environ["DJANGO_SETTINGS_MODULE"]="evennia.settings_default"
+ else:
+ os.environ["DJANGO_SETTINGS_MODULE"]=SETTINGS_DOTPATH
+
+ # required since django1.7
+ django.setup()
+
+ # test existence of the settings module
+ try:
+ fromdjango.confimportsettings
+ exceptExceptionasex:
+ ifnotstr(ex).startswith("No module named"):
+ importtraceback
+
+ print(traceback.format_exc().strip())
+ print(ERROR_SETTINGS)
+ sys.exit()
+
+ # this will both check the database and initialize the evennia dir.
+ ifcheck_db:
+ check_database()
+
+ # if we don't have to check the game directory, return right away
+ ifnotneed_gamedir:
+ return
+
+ # set up the Evennia executables and log file locations
+ globalAMP_PORT,AMP_HOST,AMP_INTERFACE
+ globalSERVER_PY_FILE,PORTAL_PY_FILE
+ globalSERVER_LOGFILE,PORTAL_LOGFILE,HTTP_LOGFILE
+ globalSERVER_PIDFILE,PORTAL_PIDFILE
+ globalSPROFILER_LOGFILE,PPROFILER_LOGFILE
+ globalEVENNIA_VERSION
+
+ AMP_PORT=settings.AMP_PORT
+ AMP_HOST=settings.AMP_HOST
+ AMP_INTERFACE=settings.AMP_INTERFACE
+
+ SERVER_PY_FILE=os.path.join(EVENNIA_LIB,"server","server.py")
+ PORTAL_PY_FILE=os.path.join(EVENNIA_LIB,"server","portal","portal.py")
+
+ SERVER_PIDFILE=os.path.join(GAMEDIR,SERVERDIR,"server.pid")
+ PORTAL_PIDFILE=os.path.join(GAMEDIR,SERVERDIR,"portal.pid")
+
+ SPROFILER_LOGFILE=os.path.join(GAMEDIR,SERVERDIR,"logs","server.prof")
+ PPROFILER_LOGFILE=os.path.join(GAMEDIR,SERVERDIR,"logs","portal.prof")
+
+ SERVER_LOGFILE=settings.SERVER_LOG_FILE
+ PORTAL_LOGFILE=settings.PORTAL_LOG_FILE
+ HTTP_LOGFILE=settings.HTTP_LOG_FILE
+
+ # verify existence of log file dir (this can be missing e.g.
+ # if the game dir itself was cloned since log files are in .gitignore)
+ logdirs=[
+ logfile.rsplit(os.path.sep,1)forlogfilein(SERVER_LOGFILE,PORTAL_LOGFILE,HTTP_LOGFILE)
+ ]
+ ifnotall(os.path.isdir(pathtup[0])forpathtupinlogdirs):
+ errstr="\n ".join(
+ "%s (log file %s)"%(pathtup[0],pathtup[1])
+ forpathtupinlogdirs
+ ifnotos.path.isdir(pathtup[0])
+ )
+ print(ERROR_LOGDIR_MISSING.format(logfiles=errstr))
+ sys.exit()
+
+ if_is_windows():
+ # We need to handle Windows twisted separately. We create a
+ # batchfile in game/server, linking to the actual binary
+
+ globalTWISTED_BINARY
+ # Windows requires us to use the absolute path for the bat file.
+ server_path=os.path.dirname(os.path.abspath(__file__))
+ TWISTED_BINARY=os.path.join(server_path,"twistd.bat")
+
+ # add path so system can find the batfile
+ sys.path.insert(1,os.path.join(GAMEDIR,SERVERDIR))
+
+ try:
+ importlib.import_module("win32api")
+ exceptImportError:
+ print(ERROR_WINDOWS_WIN32API)
+ sys.exit()
+
+ batpath=os.path.join(EVENNIA_SERVER,TWISTED_BINARY)
+ ifnotos.path.exists(batpath):
+ # Test for executable twisted batch file. This calls the
+ # twistd.py executable that is usually not found on the
+ # path in Windows. It's not enough to locate
+ # scripts.twistd, what we want is the executable script
+ # C:\PythonXX/Scripts/twistd.py. Alas we cannot hardcode
+ # this location since we don't know if user has Python in
+ # a non-standard location. So we try to figure it out.
+ twistd=importlib.import_module("twisted.scripts.twistd")
+ twistd_dir=os.path.dirname(twistd.__file__)
+
+ # note that we hope the twistd package won't change here, since we
+ # try to get to the executable by relative path.
+ # Update: In 2016, it seems Twisted 16 has changed the name of
+ # of its executable from 'twistd.py' to 'twistd.exe'.
+ twistd_path=os.path.abspath(
+ os.path.join(
+ twistd_dir,os.pardir,os.pardir,os.pardir,os.pardir,"scripts","twistd.exe"
+ )
+ )
+
+ withopen(batpath,"w")asbat_file:
+ # build a custom bat file for windows
+ bat_file.write('@"%s" %%*'%twistd_path)
+
+ print(INFO_WINDOWS_BATFILE.format(twistd_path=twistd_path))
+
+
+
[docs]defrun_dummyrunner(number_of_dummies):
+ """
+ Start an instance of the dummyrunner
+
+ Args:
+ number_of_dummies (int): The number of dummy accounts to start.
+
+ Notes:
+ The dummy accounts' behavior can be customized by adding a
+ `dummyrunner_settings.py` config file in the game's conf/
+ directory.
+
+ """
+ number_of_dummies=str(int(number_of_dummies))ifnumber_of_dummieselse1
+ cmdstr=[sys.executable,EVENNIA_DUMMYRUNNER,"-N",number_of_dummies]
+ config_file=os.path.join(SETTINGS_PATH,"dummyrunner_settings.py")
+ ifos.path.exists(config_file):
+ cmdstr.extend(["--config",config_file])
+ try:
+ call(cmdstr,env=getenv())
+ exceptKeyboardInterrupt:
+ # this signals the dummyrunner to stop cleanly and should
+ # not lead to a traceback here.
+ pass
+
+
+
[docs]defrun_connect_wizard():
+ """
+ Run the linking wizard, for adding new external connections.
+
+ """
+ from.connection_wizardimportConnectionWizard,node_start
+
+ wizard=ConnectionWizard()
+ node_start(wizard)
+
+
+
[docs]deflist_settings(keys):
+ """
+ Display the server settings. We only display the Evennia specific
+ settings here. The result will be printed to the terminal.
+
+ Args:
+ keys (str or list): Setting key or keys to inspect.
+
+ """
+ fromimportlibimportimport_module
+ fromevennia.utilsimportevtable
+
+ evsettings=import_module(SETTINGS_DOTPATH)
+ iflen(keys)==1andkeys[0].upper()=="ALL":
+ # show a list of all keys
+ # a specific key
+ table=evtable.EvTable()
+ confs=[keyforkeyinsorted(evsettings.__dict__)ifkey.isupper()]
+ foriinrange(0,len(confs),4):
+ table.add_row(*confs[i:i+4])
+ else:
+ # a specific key
+ table=evtable.EvTable(width=131)
+ keys=[key.upper()forkeyinkeys]
+ confs=dict((key,var)forkey,varinevsettings.__dict__.items()ifkeyinkeys)
+ forkey,valinconfs.items():
+ table.add_row(key,str(val))
+ print(table)
Source code for evennia.server.game_index_client.client
+"""
+The client for sending data to the Evennia Game Index
+
+"""
+importurllib.request,urllib.parse,urllib.error
+importplatform
+importwarnings
+
+importdjango
+fromdjango.confimportsettings
+fromtwisted.internetimportdefer
+fromtwisted.internetimportprotocol
+fromtwisted.internetimportreactor
+fromtwisted.internet.deferimportinlineCallbacks
+fromtwisted.web.clientimportAgent,_HTTP11ClientFactory,HTTPConnectionPool
+fromtwisted.web.http_headersimportHeaders
+fromtwisted.web.iwebimportIBodyProducer
+fromzope.interfaceimportimplementer
+
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.server.sessionhandlerimportSESSIONS
+fromevennia.utilsimportget_evennia_version,logger
+
+_EGI_HOST="http://evennia-game-index.appspot.com"
+_EGI_REPORT_PATH="/api/v1/game/check_in"
+
+
+
[docs]classEvenniaGameIndexClient(object):
+ """
+ This client class is used for gathering and sending game details to the
+ Evennia Game Index. Since EGI is in the early goings, this isn't
+ incredibly configurable as far as to what is being sent.
+ """
+
+
[docs]def__init__(self,on_bad_request=None):
+ """
+ :param on_bad_request: Optional callable to trigger when a bad request
+ was sent. This is almost always going to be due to bad config.
+ """
+ self.report_host=_EGI_HOST
+ self.report_path=_EGI_REPORT_PATH
+ self.report_url=self.report_host+self.report_path
+ self.logged_first_connect=False
+
+ self._on_bad_request=on_bad_request
+ # Oh, the humanity. Silence the factory start/stop messages.
+ self._conn_pool=HTTPConnectionPool(reactor)
+ self._conn_pool._factory=QuietHTTP11ClientFactory
+
+
[docs]@inlineCallbacks
+ defsend_game_details(self):
+ """
+ This is where the magic happens. Send details about the game to the
+ Evennia Game Index.
+ """
+ status_code,response_body=yieldself._form_and_send_request()
+ ifstatus_code==200:
+ ifnotself.logged_first_connect:
+ logger.log_infomsg("Successfully sent game details to Evennia Game Index.")
+ self.logged_first_connect=True
+ return
+ # At this point, either EGD is having issues or the payload we sent
+ # is improperly formed (probably due to mis-configuration).
+ logger.log_errmsg(
+ "Failed to send game details to Evennia Game Index. HTTP "
+ "status code was %s. Message was: %s"%(status_code,response_body)
+ )
+
+ ifstatus_code==400andself._on_bad_request:
+ # Improperly formed request. Defer to the callback as far as what
+ # to do. Probably not a great idea to continue attempting to send
+ # to EGD, though.
+ self._on_bad_request()
+
+ def_form_and_send_request(self):
+ """
+ Build the request to send to the index.
+
+ """
+ agent=Agent(reactor,pool=self._conn_pool)
+ headers={
+ b"User-Agent":[b"Evennia Game Index Client"],
+ b"Content-Type":[b"application/x-www-form-urlencoded"],
+ }
+ egi_config=settings.GAME_INDEX_LISTING
+ # We are using `or` statements below with dict.get() to avoid sending
+ # stringified 'None' values to the server.
+ try:
+ values={
+ # Game listing stuff
+ "game_name":egi_config.get("game_name",settings.SERVERNAME),
+ "game_status":egi_config["game_status"],
+ "game_website":egi_config.get("game_website",""),
+ "short_description":egi_config["short_description"],
+ "long_description":egi_config.get("long_description",""),
+ "listing_contact":egi_config["listing_contact"],
+ # How to play
+ "telnet_hostname":egi_config.get("telnet_hostname",""),
+ "telnet_port":egi_config.get("telnet_port",""),
+ "web_client_url":egi_config.get("web_client_url",""),
+ # Game stats
+ "connected_account_count":SESSIONS.account_count(),
+ "total_account_count":AccountDB.objects.num_total_accounts()or0,
+ # System info
+ "evennia_version":get_evennia_version(),
+ "python_version":platform.python_version(),
+ "django_version":django.get_version(),
+ "server_platform":platform.platform(),
+ }
+ exceptKeyErroraserr:
+ raiseKeyError(f"Error loading GAME_INDEX_LISTING: {err}")
+
+ data=urllib.parse.urlencode(values)
+
+ d=agent.request(
+ b"POST",
+ bytes(self.report_url,"utf-8"),
+ headers=Headers(headers),
+ bodyProducer=StringProducer(data),
+ )
+
+ d.addCallback(self.handle_egd_response)
+ returnd
+
+
[docs]defhandle_egd_response(self,response):
+ if200<=response.code<300:
+ d=defer.succeed((response.code,"OK"))
+ else:
+ # Go through the horrifying process of getting the response body
+ # out of Twisted's plumbing.
+ d=defer.Deferred()
+ response.deliverBody(SimpleResponseReceiver(response.code,d))
+ returnd
+
+
+
[docs]classSimpleResponseReceiver(protocol.Protocol):
+ """
+ Used for pulling the response body out of an HTTP response.
+ """
+
+
Source code for evennia.server.game_index_client.service
+"""
+Service for integrating the Evennia Game Index client into Evennia.
+
+"""
+fromtwisted.internetimportreactor
+fromtwisted.internet.taskimportLoopingCall
+fromtwisted.application.serviceimportService
+
+fromevennia.utilsimportlogger
+from.clientimportEvenniaGameIndexClient
+
+# How many seconds to wait before triggering the first EGI check-in.
+_FIRST_UPDATE_DELAY=10
+# How often to sync to the server
+_CLIENT_UPDATE_RATE=60*30
+
+
+
[docs]classEvenniaGameIndexService(Service):
+ """
+ Twisted Service that contains a LoopingCall for regularly sending game details
+ to the Evennia Game Index.
+
+ """
+
+ # We didn't stick the Evennia prefix on here because it'd get marked as
+ # a core system service.
+ name="GameIndexClient"
+
+
[docs]defstartService(self):
+ super().startService()
+ # Check to make sure that the client is configured.
+ # Start the loop, but only after a short delay. This allows the
+ # portal and the server time to sync up as far as total player counts.
+ # Prevents always reporting a count of 0.
+ reactor.callLater(_FIRST_UPDATE_DELAY,self.loop.start,_CLIENT_UPDATE_RATE)
+
+
[docs]defstopService(self):
+ ifself.running==0:
+ # reload errors if we've stopped this service.
+ return
+ super().stopService()
+ ifself.loop.running:
+ self.loop.stop()
+
+ def_die_on_bad_request(self):
+ """
+ If it becomes apparent that our configuration is generating improperly
+ formed messages to EGI, we don't want to keep sending bad messages.
+ Stop the service so we're not wasting resources.
+ """
+ logger.log_infomsg(
+ "Shutting down Evennia Game Index client service due to ""invalid configuration."
+ )
+ self.stopService()
+"""
+This module handles initial database propagation, which is only run the first
+time the game starts. It will create some default channels, objects, and
+other things.
+
+Everything starts at handle_setup()
+"""
+
+
+importtime
+fromdjango.confimportsettings
+fromdjango.utils.translationimportgettextas_
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.server.modelsimportServerConfig
+fromevennia.utilsimportcreate,logger
+
+
+ERROR_NO_SUPERUSER="""
+ No superuser exists yet. The superuser is the 'owner' account on
+ the Evennia server. Create a new superuser using the command
+
+ evennia createsuperuser
+
+ Follow the prompts, then restart the server.
+ """
+
+
+LIMBO_DESC=_(
+ """
+Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if you need
+help, want to contribute, report issues or just join the community.
+As Account #1 you can create a demo/tutorial area with '|wbatchcommand tutorial_world.build|n'.
+ """
+)
+
+
+WARNING_POSTGRESQL_FIX="""
+ PostgreSQL-psycopg2 compatibility fix:
+ The in-game channels {chan1}, {chan2} and {chan3} were created,
+ but the superuser was not yet connected to them. Please use in
+ game commands to connect Account #1 to those channels when first
+ logging in.
+ """
+
+
+
[docs]defget_god_account():
+ """
+ Creates the god user and don't take no for an answer.
+
+ """
+ try:
+ god_account=AccountDB.objects.get(id=1)
+ exceptAccountDB.DoesNotExist:
+ raiseAccountDB.DoesNotExist(ERROR_NO_SUPERUSER)
+ returngod_account
+
+
+
[docs]defcreate_objects():
+ """
+ Creates the #1 account and Limbo room.
+
+ """
+
+ logger.log_info("Initial setup: Creating objects (Account #1 and Limbo room) ...")
+
+ # Set the initial User's account object's username on the #1 object.
+ # This object is pure django and only holds name, email and password.
+ god_account=get_god_account()
+
+ # Create an Account 'user profile' object to hold eventual
+ # mud-specific settings for the AccountDB object.
+ account_typeclass=settings.BASE_ACCOUNT_TYPECLASS
+
+ # run all creation hooks on god_account (we must do so manually
+ # since the manage.py command does not)
+ god_account.swap_typeclass(account_typeclass,clean_attributes=True)
+ god_account.basetype_setup()
+ god_account.at_account_creation()
+ god_account.locks.add(
+ "examine:perm(Developer);edit:false();delete:false();boot:false();msg:all()"
+ )
+ # this is necessary for quelling to work correctly.
+ god_account.permissions.add("Developer")
+
+ # Limbo is the default "nowhere" starting room
+
+ # Create the in-game god-character for account #1 and set
+ # it to exist in Limbo.
+ character_typeclass=settings.BASE_CHARACTER_TYPECLASS
+ god_character=create.create_object(character_typeclass,key=god_account.username,nohome=True)
+
+ god_character.id=1
+ god_character.save()
+ god_character.db.desc=_("This is User #1.")
+ god_character.locks.add(
+ "examine:perm(Developer);edit:false();delete:false();boot:false();msg:all();puppet:false()"
+ )
+ # we set this low so that quelling is more useful
+ god_character.permissions.add("Player")
+
+ god_account.attributes.add("_first_login",True)
+ god_account.attributes.add("_last_puppet",god_character)
+
+ try:
+ god_account.db._playable_characters.append(god_character)
+ exceptAttributeError:
+ god_account.db_playable_characters=[god_character]
+
+ room_typeclass=settings.BASE_ROOM_TYPECLASS
+ limbo_obj=create.create_object(room_typeclass,_("Limbo"),nohome=True)
+ limbo_obj.id=2
+ limbo_obj.save()
+ limbo_obj.db.desc=LIMBO_DESC.strip()
+ limbo_obj.save()
+
+ # Now that Limbo exists, try to set the user up in Limbo (unless
+ # the creation hooks already fixed this).
+ ifnotgod_character.location:
+ god_character.location=limbo_obj
+ ifnotgod_character.home:
+ god_character.home=limbo_obj
[docs]defat_initial_setup():
+ """
+ Custom hook for users to overload some or all parts of the initial
+ setup. Called very last in the sequence. It tries to import and
+ srun a module settings.AT_INITIAL_SETUP_HOOK_MODULE and will fail
+ silently if this does not exist or fails to load.
+
+ """
+ modname=settings.AT_INITIAL_SETUP_HOOK_MODULE
+ ifnotmodname:
+ return
+ try:
+ mod=__import__(modname,fromlist=[None])
+ except(ImportError,ValueError):
+ return
+ logger.log_info("Initial setup: Running at_initial_setup() hook.")
+ ifmod.__dict__.get("at_initial_setup",None):
+ mod.at_initial_setup()
+
+
+
[docs]defcollectstatic():
+ """
+ Run collectstatic to make sure all web assets are loaded.
+
+ """
+ fromdjango.core.managementimportcall_command
+
+ logger.log_info("Initial setup: Gathering static resources using 'collectstatic'")
+ call_command("collectstatic","--noinput")
+
+
+
[docs]defreset_server():
+ """
+ We end the initialization by resetting the server. This makes sure
+ the first login is the same as all the following ones,
+ particularly it cleans all caches for the special objects. It
+ also checks so the warm-reset mechanism works as it should.
+
+ """
+ ServerConfig.objects.conf("server_epoch",time.time())
+ fromevennia.server.sessionhandlerimportSESSIONS
+
+ logger.log_info("Initial setup complete. Restarting Server once.")
+ SESSIONS.portal_reset_server()
+
+
+
[docs]defhandle_setup(last_step):
+ """
+ Main logic for the module. It allows for restarting the
+ initialization at any point if one of the modules should crash.
+
+ Args:
+ last_step (int): The last stored successful step, for starting
+ over on errors. If `< 0`, initialization has finished and no
+ steps need to be redone.
+
+ """
+
+ iflast_step<0:
+ # this means we don't need to handle setup since
+ # it already ran sucessfully once.
+ return
+ # if None, set it to 0
+ last_step=last_stepor0
+
+ # setting up the list of functions to run
+ setup_queue=[create_objects,create_channels,at_initial_setup,collectstatic,reset_server]
+
+ # step through queue, from last completed function
+ fornum,setup_funcinenumerate(setup_queue[last_step:]):
+ # run the setup function. Note that if there is a
+ # traceback we let it stop the system so the config
+ # step is not saved.
+
+ try:
+ setup_func()
+ exceptException:
+ iflast_step+num==1:
+ fromevennia.objects.modelsimportObjectDB
+
+ forobjinObjectDB.objects.all():
+ obj.delete()
+ eliflast_step+num==2:
+ fromevennia.comms.modelsimportChannelDB
+
+ ChannelDB.objects.all().delete()
+ raise
+ # save this step
+ ServerConfig.objects.conf("last_initial_setup_step",last_step+num+1)
+ # We got through the entire list. Set last_step to -1 so we don't
+ # have to run this again.
+ ServerConfig.objects.conf("last_initial_setup_step",-1)
+"""
+Functions for processing input commands.
+
+All global functions in this module whose name does not start with "_"
+is considered an inputfunc. Each function must have the following
+callsign (where inputfunc name is always lower-case, no matter what the
+OOB input name looked like):
+
+ inputfunc(session, *args, **kwargs)
+
+Where "options" is always one of the kwargs, containing eventual
+protocol-options.
+There is one special function, the "default" function, which is called
+on a no-match. It has this callsign:
+
+ default(session, cmdname, *args, **kwargs)
+
+Evennia knows which modules to use for inputfuncs by
+settings.INPUT_FUNC_MODULES.
+
+"""
+
+importimportlib
+fromcodecsimportlookupascodecs_lookup
+fromdjango.confimportsettings
+fromevennia.commands.cmdhandlerimportcmdhandler
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.utils.loggerimportlog_err
+fromevennia.utils.utilsimportto_str
+
+BrowserSessionStore=importlib.import_module(settings.SESSION_ENGINE).SessionStore
+
+
+# always let "idle" work since we use this in the webclient
+_IDLE_COMMAND=settings.IDLE_COMMAND
+_IDLE_COMMAND=(_IDLE_COMMAND,)if_IDLE_COMMAND=="idle"else(_IDLE_COMMAND,"idle")
+_GA=object.__getattribute__
+_SA=object.__setattr__
+
+
+def_NA(o):
+ return"N/A"
+
+
+_ERROR_INPUT="Inputfunc {name}({session}): Wrong/unrecognized input: {inp}"
+
+
+# All global functions are inputfuncs available to process inputs
+
+
+
[docs]deftext(session,*args,**kwargs):
+ """
+ Main text input from the client. This will execute a command
+ string on the server.
+
+ Args:
+ session (Session): The active Session to receive the input.
+ text (str): First arg is used as text-command input. Other
+ arguments are ignored.
+
+ """
+ # from evennia.server.profiling.timetrace import timetrace
+ # text = timetrace(text, "ServerSession.data_in")
+
+ txt=args[0]ifargselseNone
+
+ # explicitly check for None since text can be an empty string, which is
+ # also valid
+ iftxtisNone:
+ return
+ # this is treated as a command input
+ # handle the 'idle' command
+ iftxt.strip()in_IDLE_COMMAND:
+ session.update_session_counters(idle=True)
+ return
+ ifsession.account:
+ # nick replacement
+ puppet=session.puppet
+ ifpuppet:
+ txt=puppet.nicks.nickreplace(
+ txt,categories=("inputline","channel"),include_account=True
+ )
+ else:
+ txt=session.account.nicks.nickreplace(
+ txt,categories=("inputline","channel"),include_account=False
+ )
+ kwargs.pop("options",None)
+ cmdhandler(session,txt,callertype="session",session=session,**kwargs)
+ session.update_session_counters()
+
+
+
[docs]defbot_data_in(session,*args,**kwargs):
+ """
+ Text input from the IRC and RSS bots.
+ This will trigger the execute_cmd method on the bots in-game counterpart.
+
+ Args:
+ session (Session): The active Session to receive the input.
+ text (str): First arg is text input. Other arguments are ignored.
+
+ """
+
+ txt=args[0]ifargselseNone
+
+ # Explicitly check for None since text can be an empty string, which is
+ # also valid
+ iftxtisNone:
+ return
+ # this is treated as a command input
+ # handle the 'idle' command
+ iftxt.strip()in_IDLE_COMMAND:
+ session.update_session_counters(idle=True)
+ return
+ kwargs.pop("options",None)
+ # Trigger the execute_cmd method of the corresponding bot.
+ session.account.execute_cmd(session=session,txt=txt,**kwargs)
+ session.update_session_counters()
+
+
+
[docs]defecho(session,*args,**kwargs):
+ """
+ Echo test function
+ """
+ session.data_out(text="Echo returns: %s"%args)
+
+
+
[docs]defdefault(session,cmdname,*args,**kwargs):
+ """
+ Default catch-function. This is like all other input functions except
+ it will get `cmdname` as the first argument.
+
+ """
+ err=(
+ "Session {sessid}: Input command not recognized:\n"
+ " name: '{cmdname}'\n"
+ " args, kwargs: {args}, {kwargs}".format(
+ sessid=session.sessid,cmdname=cmdname,args=args,kwargs=kwargs
+ )
+ )
+ ifsession.protocol_flags.get("INPUTDEBUG",False):
+ session.msg(err)
+ log_err(err)
[docs]defclient_options(session,*args,**kwargs):
+ """
+ This allows the client an OOB way to inform us about its name and capabilities.
+ This will be integrated into the session settings
+
+ Keyword Args:
+ get (bool): If this is true, return the settings as a dict
+ (ignore all other kwargs).
+ client (str): A client identifier, like "mushclient".
+ version (str): A client version
+ ansi (bool): Supports ansi colors
+ xterm256 (bool): Supports xterm256 colors or not
+ mxp (bool): Supports MXP or not
+ utf-8 (bool): Supports UTF-8 or not
+ screenreader (bool): Screen-reader mode on/off
+ mccp (bool): MCCP compression on/off
+ screenheight (int): Screen height in lines
+ screenwidth (int): Screen width in characters
+ inputdebug (bool): Debug input functions
+ nocolor (bool): Strip color
+ raw (bool): Turn off parsing
+
+ """
+ old_flags=session.protocol_flags
+ ifnotkwargsorkwargs.get("get",False):
+ # return current settings
+ options=dict((key,old_flags[key])forkeyinold_flagsifkey.upper()in_CLIENT_OPTIONS)
+ session.msg(client_options=options)
+ return
+
+ defvalidate_encoding(val):
+ # helper: change encoding
+ try:
+ codecs_lookup(val)
+ exceptLookupError:
+ raiseRuntimeError("The encoding '|w%s|n' is invalid. "%val)
+ returnval
+
+ defvalidate_size(val):
+ return{0:int(val)}
+
+ defvalidate_bool(val):
+ ifisinstance(val,str):
+ returnTrueifval.lower()in("true","on","1")elseFalse
+ returnbool(val)
+
+ flags={}
+ forkey,valueinkwargs.items():
+ key=key.lower()
+ ifkey=="client":
+ flags["CLIENTNAME"]=to_str(value)
+ elifkey=="version":
+ if"CLIENTNAME"inflags:
+ flags["CLIENTNAME"]="%s%s"%(flags["CLIENTNAME"],to_str(value))
+ elifkey=="ENCODING":
+ flags["ENCODING"]=validate_encoding(value)
+ elifkey=="ansi":
+ flags["ANSI"]=validate_bool(value)
+ elifkey=="xterm256":
+ flags["XTERM256"]=validate_bool(value)
+ elifkey=="mxp":
+ flags["MXP"]=validate_bool(value)
+ elifkey=="utf-8":
+ flags["UTF-8"]=validate_bool(value)
+ elifkey=="screenreader":
+ flags["SCREENREADER"]=validate_bool(value)
+ elifkey=="mccp":
+ flags["MCCP"]=validate_bool(value)
+ elifkey=="screenheight":
+ flags["SCREENHEIGHT"]=validate_size(value)
+ elifkey=="screenwidth":
+ flags["SCREENWIDTH"]=validate_size(value)
+ elifkey=="inputdebug":
+ flags["INPUTDEBUG"]=validate_bool(value)
+ elifkey=="nocolor":
+ flags["NOCOLOR"]=validate_bool(value)
+ elifkey=="raw":
+ flags["RAW"]=validate_bool(value)
+ elifkey=="nogoahead":
+ flags["NOGOAHEAD"]=validate_bool(value)
+ elifkeyin(
+ "Char 1",
+ "Char.Skills 1",
+ "Char.Items 1",
+ "Room 1",
+ "IRE.Rift 1",
+ "IRE.Composer 1",
+ ):
+ # ignore mudlet's default send (aimed at IRE games)
+ pass
+ elifkeynotin("options","cmdid"):
+ err=_ERROR_INPUT.format(name="client_settings",session=session,inp=key)
+ session.msg(text=err)
+
+ session.protocol_flags.update(flags)
+ # we must update the protocol flags on the portal session copy as well
+ session.sessionhandler.session_portal_partial_sync({session.sessid:{"protocol_flags":flags}})
+
+
+
[docs]defget_client_options(session,*args,**kwargs):
+ """
+ Alias wrapper for getting options.
+ """
+ client_options(session,get=True)
+
+
+
[docs]defget_inputfuncs(session,*args,**kwargs):
+ """
+ Get the keys of all available inputfuncs. Note that we don't get
+ it from this module alone since multiple modules could be added.
+ So we get it from the sessionhandler.
+ """
+ inputfuncsdict=dict(
+ (key,func.__doc__)forkey,funcinsession.sessionhandler.get_inputfuncs().items()
+ )
+ session.msg(get_inputfuncs=inputfuncsdict)
+
+
+
[docs]deflogin(session,*args,**kwargs):
+ """
+ Peform a login. This only works if session is currently not logged
+ in. This will also automatically throttle too quick attempts.
+
+ Keyword Args:
+ name (str): Account name
+ password (str): Plain-text password
+
+ """
+ ifnotsession.logged_inand"name"inkwargsand"password"inkwargs:
+ fromevennia.commands.default.unloggedinimportcreate_normal_account
+
+ account=create_normal_account(session,kwargs["name"],kwargs["password"])
+ ifaccount:
+ session.sessionhandler.login(session,account)
[docs]defget_value(session,*args,**kwargs):
+ """
+ Return the value of a given attribute or db_property on the
+ session's current account or character.
+
+ Keyword Args:
+ name (str): Name of info value to return. Only names
+ in the _gettable dictionary earlier in this module
+ are accepted.
+
+ """
+ name=kwargs.get("name","")
+ obj=session.puppetorsession.account
+ ifnamein_gettable:
+ session.msg(get_value={"name":name,"value":_gettable[name](obj)})
+
+
+def_testrepeat(**kwargs):
+ """
+ This is a test function for using with the repeat
+ inputfunc.
+
+ Keyword Args:
+ session (Session): Session to return to.
+ """
+ importtime
+
+ kwargs["session"].msg(repeat="Repeat called: %s"%time.time())
+
+
+_repeatable={"test1":_testrepeat,"test2":_testrepeat}# example only # "
+
+
+
[docs]defrepeat(session,*args,**kwargs):
+ """
+ Call a named function repeatedly. Note that
+ this is meant as an example of limiting the number of
+ possible call functions.
+
+ Keyword Args:
+ callback (str): The function to call. Only functions
+ from the _repeatable dictionary earlier in this
+ module are available.
+ interval (int): How often to call function (s).
+ Defaults to once every 60 seconds with a minimum
+ of 5 seconds.
+ stop (bool): Stop a previously assigned ticker with
+ the above settings.
+
+ """
+ fromevennia.scripts.tickerhandlerimportTICKER_HANDLER
+
+ name=kwargs.get("callback","")
+ interval=max(5,int(kwargs.get("interval",60)))
+
+ ifnamein_repeatable:
+ ifkwargs.get("stop",False):
+ TICKER_HANDLER.remove(
+ interval,_repeatable[name],idstring=session.sessid,persistent=False
+ )
+ else:
+ TICKER_HANDLER.add(
+ interval,
+ _repeatable[name],
+ idstring=session.sessid,
+ persistent=False,
+ session=session,
+ )
+ else:
+ session.msg("Allowed repeating functions are: %s"%(", ".join(_repeatable)))
+
+
+
[docs]defunrepeat(session,*args,**kwargs):
+ "Wrapper for OOB use"
+ kwargs["stop"]=True
+ repeat(session,*args,**kwargs)
+
+
+_monitorable={"name":"db_key","location":"db_location","desc":"desc"}
+
+
+def_on_monitor_change(**kwargs):
+ fieldname=kwargs["fieldname"]
+ obj=kwargs["obj"]
+ name=kwargs["name"]
+ session=kwargs["session"]
+ outputfunc_name=kwargs["outputfunc_name"]
+
+ # the session may be None if the char quits and someone
+ # else then edits the object
+
+ ifsession:
+ callsign={outputfunc_name:{"name":name,"value":_GA(obj,fieldname)}}
+ session.msg(**callsign)
+
+
+
[docs]defmonitor(session,*args,**kwargs):
+ """
+ Adds monitoring to a given property or Attribute.
+
+ Keyword Args:
+ name (str): The name of the property or Attribute
+ to report. No db_* prefix is needed. Only names
+ in the _monitorable dict earlier in this module
+ are accepted.
+ stop (bool): Stop monitoring the above name.
+ outputfunc_name (str, optional): Change the name of
+ the outputfunc name. This is used e.g. by MSDP which
+ has its own specific output format.
+
+ """
+ fromevennia.scripts.monitorhandlerimportMONITOR_HANDLER
+
+ name=kwargs.get("name",None)
+ outputfunc_name=kwargs("outputfunc_name","monitor")
+ ifnameandnamein_monitorableandsession.puppet:
+ field_name=_monitorable[name]
+ obj=session.puppet
+ ifkwargs.get("stop",False):
+ MONITOR_HANDLER.remove(obj,field_name,idstring=session.sessid)
+ else:
+ # the handler will add fieldname and obj to the kwargs automatically
+ MONITOR_HANDLER.add(
+ obj,
+ field_name,
+ _on_monitor_change,
+ idstring=session.sessid,
+ persistent=False,
+ name=name,
+ session=session,
+ outputfunc_name=outputfunc_name,
+ )
+
+
+
[docs]defunmonitor(session,*args,**kwargs):
+ """
+ Wrapper for turning off monitoring
+ """
+ kwargs["stop"]=True
+ monitor(session,*args,**kwargs)
+
+
+
[docs]defmonitored(session,*args,**kwargs):
+ """
+ Report on what is being monitored
+
+ """
+ fromevennia.scripts.monitorhandlerimportMONITOR_HANDLER
+
+ obj=session.puppet
+ monitors=MONITOR_HANDLER.all(obj=obj)
+ session.msg(monitored=(monitors,{}))
+
+
+def_on_webclient_options_change(**kwargs):
+ """
+ Called when the webclient options stored on the account changes.
+ Inform the interested clients of this change.
+ """
+ session=kwargs["session"]
+ obj=kwargs["obj"]
+ fieldname=kwargs["fieldname"]
+ clientoptions=_GA(obj,fieldname)
+
+ # the session may be None if the char quits and someone
+ # else then edits the object
+ ifsession:
+ session.msg(webclient_options=clientoptions)
+
+
+
[docs]defwebclient_options(session,*args,**kwargs):
+ """
+ Handles retrieving and changing of options related to the webclient.
+
+ If kwargs is empty (or contains just a "cmdid"), the saved options will be
+ sent back to the session.
+ A monitor handler will be created to inform the client of any future options
+ that changes.
+
+ If kwargs is not empty, the key/values stored in there will be persisted
+ to the account object.
+
+ Keyword Args:
+ <option name>: an option to save
+
+ """
+ account=session.account
+
+ clientoptions=account.db._saved_webclient_options
+ ifnotclientoptions:
+ # No saved options for this account, copy and save the default.
+ account.db._saved_webclient_options=settings.WEBCLIENT_OPTIONS.copy()
+ # Get the _SaverDict created by the database.
+ clientoptions=account.db._saved_webclient_options
+
+ # The webclient adds a cmdid to every kwargs, but we don't need it.
+ try:
+ delkwargs["cmdid"]
+ exceptKeyError:
+ pass
+
+ ifnotkwargs:
+ # No kwargs: we are getting the stored options
+ # Convert clientoptions to regular dict for sending.
+ session.msg(webclient_options=dict(clientoptions))
+
+ # Create a monitor. If a monitor already exists then it will replace
+ # the previous one since it would use the same idstring
+ fromevennia.scripts.monitorhandlerimportMONITOR_HANDLER
+
+ MONITOR_HANDLER.add(
+ account,
+ "_saved_webclient_options",
+ _on_webclient_options_change,
+ idstring=session.sessid,
+ persistent=False,
+ session=session,
+ )
+ else:
+ # kwargs provided: persist them to the account object.
+ clientoptions.update(kwargs)
+
+
+# OOB protocol-specific aliases and wrappers
+
+# GMCP aliases
+hello=client_options
+supports_set=client_options
+
+
+# MSDP aliases (some of the the generic MSDP commands defined in the MSDP spec are prefixed
+# by msdp_ at the protocol level)
+# See https://tintin.sourceforge.io/protocols/msdp/
+
+
+
+
+
+# client specific
+
+
+def_not_implemented(session,*args,**kwargs):
+ """
+ Dummy used to swallow missing-inputfunc errors for
+ common clients.
+ """
+ pass
+
+
+# GMCP External.Discord.Hello is sent by Mudlet as a greeting
+# (see https://wiki.mudlet.org/w/Manual:Technical_Manual)
+external_discord_hello=_not_implemented
+
+
+# GMCP Client.Gui is sent by Mudlet for gui setup.
+client_gui=_not_implemented
+
[docs]classServerConfigManager(models.Manager):
+ """
+ This ServerConfigManager implements methods for searching and
+ manipulating ServerConfigs directly from the database.
+
+ These methods will all return database objects (or QuerySets)
+ directly.
+
+ ServerConfigs are used to store certain persistent settings for
+ the server at run-time.
+
+ """
+
+
[docs]defconf(self,key=None,value=None,delete=False,default=None):
+ """
+ Add, retrieve and manipulate config values.
+
+ Args:
+ key (str, optional): Name of config.
+ value (str, optional): Data to store in this config value.
+ delete (bool, optional): If `True`, delete config with `key`.
+ default (str, optional): Use when retrieving a config value
+ by a key that does not exist.
+ Returns:
+ all (list): If `key` was not given - all stored config values.
+ value (str): If `key` was given, this is the stored value, or
+ `default` if no matching `key` was found.
+
+ """
+ ifnotkey:
+ returnself.all()
+ elifdeleteisTrue:
+ forconfinself.filter(db_key=key):
+ conf.delete()
+ elifvalueisnotNone:
+ conf=self.filter(db_key=key)
+ ifconf:
+ conf=conf[0]
+ else:
+ conf=self.model(db_key=key)
+ conf.value=value# this will pickle
+ else:
+ conf=self.filter(db_key=key)
+ ifnotconf:
+ returndefault
+ returnconf[0].value
+ returnNone
+"""
+
+Server Configuration flags
+
+This holds persistent server configuration flags.
+
+Config values should usually be set through the
+manager's conf() method.
+
+"""
+importpickle
+
+fromdjango.dbimportmodels
+fromevennia.utils.idmapper.modelsimportWeakSharedMemoryModel
+fromevennia.utilsimportlogger,utils
+fromevennia.utils.dbserializeimportto_pickle,from_pickle
+fromevennia.server.managerimportServerConfigManager
+fromevennia.utilsimportpicklefield
+
+
+# ------------------------------------------------------------
+#
+# ServerConfig
+#
+# ------------------------------------------------------------
+
+
+
[docs]classServerConfig(WeakSharedMemoryModel):
+ """
+ On-the fly storage of global settings.
+
+ Properties defined on ServerConfig:
+
+ - key: Main identifier
+ - value: Value stored in key. This is a pickled storage.
+
+ """
+
+ #
+ # ServerConfig database model setup
+ #
+ #
+ # These database fields are all set using their corresponding properties,
+ # named same as the field, but without the db_* prefix.
+
+ # main name of the database entry
+ db_key=models.CharField(max_length=64,unique=True)
+ # config value
+ # db_value = models.BinaryField(blank=True)
+
+ db_value=picklefield.PickledObjectField(
+ "value",
+ null=True,
+ help_text="The data returned when the config value is accessed. Must be "
+ "written as a Python literal if editing through the admin "
+ "interface. Attribute values which are not Python literals "
+ "cannot be edited through the admin interface.",
+ )
+
+ objects=ServerConfigManager()
+ _is_deleted=False
+
+ # Wrapper properties to easily set database fields. These are
+ # @property decorators that allows to access these fields using
+ # normal python operations (without having to remember to save()
+ # etc). So e.g. a property 'attr' has a get/set/del decorator
+ # defined that allows the user to do self.attr = value,
+ # value = self.attr and del self.attr respectively (where self
+ # is the object in question).
+
+ # key property (wraps db_key)
+ # @property
+ def__key_get(self):
+ "Getter. Allows for value = self.key"
+ returnself.db_key
+
+ # @key.setter
+ def__key_set(self,value):
+ "Setter. Allows for self.key = value"
+ self.db_key=value
+ self.save()
+
+ # @key.deleter
+ def__key_del(self):
+ "Deleter. Allows for del self.key. Deletes entry."
+ self.delete()
+
+ key=property(__key_get,__key_set,__key_del)
+
+ # value property (wraps db_value)
+ # @property
+ def__value_get(self):
+ "Getter. Allows for value = self.value"
+ returnfrom_pickle(self.db_value,db_obj=self)
+
+ # @value.setter
+ def__value_set(self,value):
+ "Setter. Allows for self.value = value"
+ ifutils.has_parent("django.db.models.base.Model",value):
+ # we have to protect against storing db objects.
+ logger.log_err("ServerConfig cannot store db objects! (%s)"%value)
+ return
+ self.db_value=to_pickle(value)
+ self.save()
+
+ # @value.deleter
+ def__value_del(self):
+ "Deleter. Allows for del self.value. Deletes entry."
+ self.delete()
+
+ value=property(__value_get,__value_set,__value_del)
+
+ classMeta(object):
+ "Define Django meta options"
+ verbose_name="Server Config value"
+ verbose_name_plural="Server Config values"
+
+ #
+ # ServerConfig other methods
+ #
+
+ def__repr__(self):
+ return"<{}{}>".format(self.__class__.__name__,self.key,self.value)
+
+
[docs]defstore(self,key,value):
+ """
+ Wrap the storage.
+
+ Args:
+ key (str): The name of this store.
+ value (str): The data to store with this `key`.
+
+ """
+ self.key=key
+ self.value=value
+"""
+The AMP (Asynchronous Message Protocol)-communication commands and constants used by Evennia.
+
+This module acts as a central place for AMP-servers and -clients to get commands to use.
+
+"""
+
+fromfunctoolsimportwraps
+importtime
+fromtwisted.protocolsimportamp
+fromcollectionsimportdefaultdict,namedtuple
+fromioimportBytesIO
+fromitertoolsimportcount
+importzlib# Used in Compressed class
+importpickle
+
+fromtwisted.internet.deferimportDeferredList,Deferred
+fromevennia.utils.utilsimportto_str,variable_from_module
+
+# delayed import
+_LOGGER=None
+
+# communication bits
+# (chr(9) and chr(10) are \t and \n, so skipping them)
+
+PCONN=chr(1)# portal session connect
+PDISCONN=chr(2)# portal session disconnect
+PSYNC=chr(3)# portal session sync
+SLOGIN=chr(4)# server session login
+SDISCONN=chr(5)# server session disconnect
+SDISCONNALL=chr(6)# server session disconnect all
+SSHUTD=chr(7)# server shutdown
+SSYNC=chr(8)# server session sync
+SCONN=chr(11)# server creating new connection (for irc bots and etc)
+PCONNSYNC=chr(12)# portal post-syncing a session
+PDISCONNALL=chr(13)# portal session disconnect all
+SRELOAD=chr(14)# server shutdown in reload mode
+SSTART=chr(15)# server start (portal must already be running anyway)
+PSHUTD=chr(16)# portal (+server) shutdown
+SSHUTD=chr(17)# server shutdown
+PSTATUS=chr(18)# ping server or portal status
+SRESET=chr(19)# server shutdown in reset mode
+
+NUL=b"\x00"
+NULNUL=b"\x00\x00"
+
+AMP_MAXLEN=amp.MAX_VALUE_LENGTH# max allowed data length in AMP protocol (cannot be changed)
+
+# amp internal
+ASK=b'_ask'
+ANSWER=b'_answer'
+ERROR=b'_error'
+ERROR_CODE=b'_error_code'
+ERROR_DESCRIPTION=b'_error_description'
+UNKNOWN_ERROR_CODE=b'UNKNOWN'
+
+# buffers
+_SENDBATCH=defaultdict(list)
+_MSGBUFFER=defaultdict(list)
+
+# resources
+
+DUMMYSESSION=namedtuple("DummySession",["sessid"])(0)
+
+
+_HTTP_WARNING=bytes(
+ """
+HTTP/1.1 200 OK
+Content-Type: text/html
+
+<html>
+ <body>
+ This is Evennia's internal AMP port. It handles communication
+ between Evennia's different processes.
+ <p>
+ <h3>This port should NOT be publicly visible.</h3>
+ </p>
+ </body>
+</html>""".strip(),
+ "utf-8",
+)
+
+
+# Helper functions for pickling.
+
+
+
+
+
+def_get_logger():
+ "Delay import of logger until absolutely necessary"
+ global_LOGGER
+ ifnot_LOGGER:
+ fromevennia.utilsimportloggeras_LOGGER
+ return_LOGGER
+
+
+@wraps
+defcatch_traceback(func):
+ "Helper decorator"
+
+ defdecorator(*args,**kwargs):
+ try:
+ func(*args,**kwargs)
+ exceptExceptionaserr:
+ _get_logger().log_trace()
+ raise# make sure the error is visible on the other side of the connection too
+ print(err)
+
+ returndecorator
+
+
+# AMP Communication Command types
+
+
+
[docs]classCompressed(amp.String):
+ """
+ This is a custom AMP command Argument that both handles too-long
+ sends as well as uses zlib for compression across the wire. The
+ batch-grouping of too-long sends is borrowed from the "mediumbox"
+ recipy at twisted-hacks's ~glyph/+junk/amphacks/mediumbox.
+
+ """
+
+
[docs]deffromBox(self,name,strings,objects,proto):
+ """
+ Converts from box string representation to python. We read back too-long batched data and
+ put it back together here.
+
+ """
+
+ value=BytesIO()
+ value.write(self.fromStringProto(strings.get(name),proto))
+ forcounterincount(2):
+ # count from 2 upwards
+ chunk=strings.get(b"%s.%d"%(name,counter))
+ ifchunkisNone:
+ break
+ value.write(self.fromStringProto(chunk,proto))
+ objects[str(name,"utf-8")]=value.getvalue()
+
+
[docs]deftoBox(self,name,strings,objects,proto):
+ """
+ Convert from python object to string box representation.
+ we break up too-long data snippets into multiple batches here.
+
+ """
+
+ # print("toBox: name={}, strings={}, objects={}, proto{}".format(name, strings, objects, proto))
+
+ value=BytesIO(objects[str(name,"utf-8")])
+ strings[name]=self.toStringProto(value.read(AMP_MAXLEN),proto)
+
+ # print("toBox strings[name] = {}".format(strings[name]))
+
+ forcounterincount(2):
+ chunk=value.read(AMP_MAXLEN)
+ ifnotchunk:
+ break
+ strings[b"%s.%d"%(name,counter)]=self.toStringProto(chunk,proto)
+
+
[docs]deftoString(self,inObject):
+ """
+ Convert to send as a bytestring on the wire, with compression.
+
+ Note: In Py3 this is really a byte stream.
+
+ """
+ returnzlib.compress(super(Compressed,self).toString(inObject),9)
+
+
[docs]deffromString(self,inString):
+ """
+ Convert (decompress) from the string-representation on the wire to Python.
+
+ """
+ returnsuper(Compressed,self).fromString(zlib.decompress(inString))
[docs]classAdminPortal2Server(amp.Command):
+ """
+ Administration Portal -> Server
+
+ Sent when the portal needs to perform admin operations on the
+ server, such as when a new session connects or resyncs
+
+ """
+
+ key="AdminPortal2Server"
+ arguments=[(b"packed_data",Compressed())]
+ errors={Exception:b"EXCEPTION"}
+ response=[]
+
+
+
[docs]classAdminServer2Portal(amp.Command):
+ """
+ Administration Server -> Portal
+
+ Sent when the server needs to perform admin operations on the
+ portal.
+
+ """
+
+ key="AdminServer2Portal"
+ arguments=[(b"packed_data",Compressed())]
+ errors={Exception:b"EXCEPTION"}
+ response=[]
[docs]classFunctionCall(amp.Command):
+ """
+ Bidirectional Server <-> Portal
+
+ Sent when either process needs to call an arbitrary function in
+ the other. This does not use the batch-send functionality.
+
+ """
+
+ key="FunctionCall"
+ arguments=[
+ (b"module",amp.String()),
+ (b"function",amp.String()),
+ (b"args",amp.String()),
+ (b"kwargs",amp.String()),
+ ]
+ errors={Exception:b"EXCEPTION"}
+ response=[(b"result",amp.String())]
+
+
+# -------------------------------------------------------------
+# Core AMP protocol for communication Server <-> Portal
+# -------------------------------------------------------------
+
+
+
[docs]classAMPMultiConnectionProtocol(amp.AMP):
+ """
+ AMP protocol that safely handle multiple connections to the same
+ server without dropping old ones - new clients will receive
+ all server returns (broadcast). Will also correctly handle
+ erroneous HTTP requests on the port and return a HTTP error response.
+
+ """
+
+ # helper methods
+
+
[docs]def__init__(self,*args,**kwargs):
+ """
+ Initialize protocol with some things that need to be in place
+ already before connecting both on portal and server.
+
+ """
+ self.send_batch_counter=0
+ self.send_reset_time=time.time()
+ self.send_mode=True
+ self.send_task=None
+ self.multibatches=0
+ # later twisted amp has its own __init__
+ super(AMPMultiConnectionProtocol,self).__init__(*args,**kwargs)
+
+ def_commandReceived(self,box):
+ """
+ This overrides the default Twisted AMP error handling which is not
+ passing enough of the traceback through to the other side. Instead we
+ add a specific log of the problem on the erroring side.
+
+ """
+ defformatAnswer(answerBox):
+ answerBox[ANSWER]=box[ASK]
+ returnanswerBox
+
+ defformatError(error):
+ iferror.check(amp.RemoteAmpError):
+ code=error.value.errorCode
+ desc=error.value.description
+
+ # Evennia extra logging
+ desc+=" (error logged on other side)"
+ _get_logger().log_err(f"AMP caught exception ({desc}):\n{error.value}")
+
+ ifisinstance(desc,str):
+ desc=desc.encode("utf-8","replace")
+ iferror.value.fatal:
+ errorBox=amp.QuitBox()
+ else:
+ errorBox=amp.AmpBox()
+ else:
+ errorBox=amp.QuitBox()
+ _get_logger().log_err(error)# server-side logging if unhandled error
+ code=UNKNOWN_ERROR_CODE
+ desc=b"Unknown Error"
+ errorBox[ERROR]=box[ASK]
+ errorBox[ERROR_DESCRIPTION]=desc
+ errorBox[ERROR_CODE]=code
+ returnerrorBox
+ deferred=self.dispatchCommand(box)
+ ifASKinbox:
+ deferred.addCallbacks(formatAnswer,formatError)
+ deferred.addCallback(self._safeEmit)
+ deferred.addErrback(self.unhandledError)
+
+
[docs]defdataReceived(self,data):
+ """
+ Handle non-AMP messages, such as HTTP communication.
+ """
+ # print("dataReceived: {}".format(data))
+ ifdata[:1]==NUL:
+ # an AMP communication
+ ifdata[-2:]!=NULNUL:
+ # an incomplete AMP box means more batches are forthcoming.
+ self.multibatches+=1
+ try:
+ super(AMPMultiConnectionProtocol,self).dataReceived(data)
+ exceptKeyError:
+ _get_logger().log_trace(
+ "Discarded incoming partial (packed) data (len {})".format(len(data))
+ )
+ elifself.multibatches:
+ # invalid AMP, but we have a pending multi-batch that is not yet complete
+ ifdata[-2:]==NULNUL:
+ # end of existing multibatch
+ self.multibatches=max(0,self.multibatches-1)
+ try:
+ super(AMPMultiConnectionProtocol,self).dataReceived(data)
+ exceptKeyError:
+ _get_logger().log_trace(
+ "Discarded incoming multi-batch (packed) data (len {})".format(len(data))
+ )
+ else:
+ # not an AMP communication, return warning
+ self.transport.write(_HTTP_WARNING)
+ self.transport.loseConnection()
+ print("HTTP received (the AMP port should not receive http, only AMP!) %s"%data)
+
+
[docs]defmakeConnection(self,transport):
+ """
+ Swallow connection log message here. Copied from original
+ in the amp protocol.
+
+ """
+ # copied from original, removing the log message
+ ifnotself._ampInitialized:
+ amp.AMP.__init__(self)
+ self._transportPeer=transport.getPeer()
+ self._transportHost=transport.getHost()
+ amp.BinaryBoxProtocol.makeConnection(self,transport)
+
+
[docs]defconnectionMade(self):
+ """
+ This is called when an AMP connection is (re-)established. AMP calls it on both sides.
+
+ """
+ # print("connectionMade: {}".format(self))
+ self.factory.broadcasts.append(self)
+
+
[docs]defconnectionLost(self,reason):
+ """
+ We swallow connection errors here. The reason is that during a
+ normal reload/shutdown there will almost always be cases where
+ either the portal or server shuts down before a message has
+ returned its (empty) return, triggering a connectionLost error
+ that is irrelevant. If a true connection error happens, the
+ portal will continuously try to reconnect, showing the problem
+ that way.
+ """
+ # print("ConnectionLost: {}: {}".format(self, reason))
+ try:
+ self.factory.broadcasts.remove(self)
+ exceptValueError:
+ pass
+
+ # Error handling
+
+
[docs]deferrback(self,e,info):
+ """
+ Error callback.
+ Handles errors to avoid dropping connections on server tracebacks.
+
+ Args:
+ e (Failure): Deferred error instance.
+ info (str): Error string.
+
+ """
+ e.trap(Exception)
+ _get_logger().log_err(
+ "AMP Error from {info}: {trcbck}{err}".format(
+ info=info,trcbck=e.getTraceback(),err=e.getErrorMessage()
+ )
+ )
[docs]defbroadcast(self,command,sessid,**kwargs):
+ """
+ Send data across the wire to all connections.
+
+ Args:
+ command (AMP Command): A protocol send command.
+ sessid (int): A unique Session id.
+
+ Returns:
+ deferred (deferred or None): A deferred with an errback.
+
+ Notes:
+ Data will be sent across the wire pickled as a tuple
+ (sessid, kwargs).
+
+ """
+ deferreds=[]
+ # print("broadcast: {} {}: {}".format(command, sessid, kwargs))
+
+ forprotclinself.factory.broadcasts:
+ deferreds.append(
+ protcl.callRemote(command,**kwargs).addErrback(self.errback,command.key)
+ )
+
+ returnDeferredList(deferreds)
+
+ # generic function send/recvs
+
+
[docs]defsend_FunctionCall(self,modulepath,functionname,*args,**kwargs):
+ """
+ Access method called by either process. This will call an arbitrary
+ function on the other process (On Portal if calling from Server and
+ vice versa).
+
+ Inputs:
+ modulepath (str) - python path to module holding function to call
+ functionname (str) - name of function in given module
+ *args, **kwargs will be used as arguments/keyword args for the
+ remote function call
+ Returns:
+ A deferred that fires with the return value of the remote
+ function call
+
+ """
+ return(
+ self.callRemote(
+ FunctionCall,
+ module=modulepath,
+ function=functionname,
+ args=dumps(args),
+ kwargs=dumps(kwargs),
+ )
+ .addCallback(lambdar:loads(r["result"]))
+ .addErrback(self.errback,"FunctionCall")
+ )
+
+
[docs]@FunctionCall.responder
+ @catch_traceback
+ defreceive_functioncall(self,module,function,func_args,func_kwargs):
+ """
+ This allows Portal- and Server-process to call an arbitrary
+ function in the other process. It is intended for use by
+ plugin modules.
+
+ Args:
+ module (str or module): The module containing the
+ `function` to call.
+ function (str): The name of the function to call in
+ `module`.
+ func_args (str): Pickled args tuple for use in `function` call.
+ func_kwargs (str): Pickled kwargs dict for use in `function` call.
+
+ """
+ args=loads(func_args)
+ kwargs=loads(func_kwargs)
+
+ # call the function (don't catch tracebacks here)
+ result=variable_from_module(module,function)(*args,**kwargs)
+
+ ifisinstance(result,Deferred):
+ # if result is a deferred, attach handler to properly
+ # wrap the return value
+ result.addCallback(lambdar:{"result":dumps(r)})
+ returnresult
+ else:
+ return{"result":dumps(result)}
+"""
+The Evennia Portal service acts as an AMP-server, handling AMP
+communication to the AMP clients connecting to it (by default
+these are the Evennia Server and the evennia launcher).
+
+"""
+importos
+importsys
+fromtwisted.internetimportprotocol
+fromevennia.server.portalimportamp
+fromdjango.confimportsettings
+fromsubprocessimportPopen,STDOUT
+fromevennia.utilsimportlogger
+
+
+def_is_windows():
+ returnos.name=="nt"
+
+
+
[docs]defgetenv():
+ """
+ Get current environment and add PYTHONPATH.
+
+ Returns:
+ env (dict): Environment global dict.
+
+ """
+ sep=";"if_is_windows()else":"
+ env=os.environ.copy()
+ env["PYTHONPATH"]=sep.join(sys.path)
+ returnenv
+
+
+
[docs]classAMPServerFactory(protocol.ServerFactory):
+
+ """
+ This factory creates AMP Server connection. This acts as the 'Portal'-side communication to the
+ 'Server' process.
+
+ """
+
+ noisy=False
+
+
[docs]deflogPrefix(self):
+ "How this is named in logs"
+ return"AMP"
+
+
[docs]def__init__(self,portal):
+ """
+ Initialize the factory. This is called as the Portal service starts.
+
+ Args:
+ portal (Portal): The Evennia Portal service instance.
+ protocol (Protocol): The protocol the factory creates
+ instances of.
+
+ """
+ self.portal=portal
+ self.protocol=AMPServerProtocol
+ self.broadcasts=[]
+ self.server_connection=None
+ self.launcher_connection=None
+ self.disconnect_callbacks={}
+ self.server_connect_callbacks=[]
+
+
[docs]defbuildProtocol(self,addr):
+ """
+ Start a new connection, and store it on the service object.
+
+ Args:
+ addr (str): Connection address. Not used.
+
+ Returns:
+ protocol (Protocol): The created protocol.
+
+ """
+ self.portal.amp_protocol=AMPServerProtocol()
+ self.portal.amp_protocol.factory=self
+ returnself.portal.amp_protocol
+
+
+
[docs]classAMPServerProtocol(amp.AMPMultiConnectionProtocol):
+ """
+ Protocol subclass for the AMP-server run by the Portal.
+
+ """
+
+
[docs]defconnectionLost(self,reason):
+ """
+ Set up a simple callback mechanism to let the amp-server wait for a connection to close.
+
+ """
+ # wipe broadcast and data memory
+ super(AMPServerProtocol,self).connectionLost(reason)
+ ifself.factory.server_connection==self:
+ self.factory.server_connection=None
+ self.factory.portal.server_info_dict={}
+ ifself.factory.launcher_connection==self:
+ self.factory.launcher_connection=None
+
+ callback,args,kwargs=self.factory.disconnect_callbacks.pop(self,(None,None,None))
+ ifcallback:
+ try:
+ callback(*args,**kwargs)
+ exceptException:
+ logger.log_trace()
+
+
[docs]defget_status(self):
+ """
+ Return status for the Evennia infrastructure.
+
+ Returns:
+ status (tuple): The portal/server status and pids
+ (portal_live, server_live, portal_PID, server_PID).
+
+ """
+ server_connected=bool(
+ self.factory.server_connectionandself.factory.server_connection.transport.connected
+ )
+ portal_info_dict=self.factory.portal.get_info_dict()
+ server_info_dict=self.factory.portal.server_info_dict
+ server_pid=self.factory.portal.server_process_id
+ portal_pid=os.getpid()
+ return(True,server_connected,portal_pid,server_pid,portal_info_dict,server_info_dict)
+
+
[docs]defdata_to_server(self,command,sessid,**kwargs):
+ """
+ Send data across the wire to the Server.
+
+ Args:
+ command (AMP Command): A protocol send command.
+ sessid (int): A unique Session id.
+ kwargs (any): Data to send. This will be pickled.
+
+ Returns:
+ deferred (deferred or None): A deferred with an errback.
+
+ Notes:
+ Data will be sent across the wire pickled as a tuple
+ (sessid, kwargs).
+
+ """
+ # print("portal data_to_server: {}, {}, {}".format(command, sessid, kwargs))
+ ifself.factory.server_connection:
+ returnself.factory.server_connection.callRemote(
+ command,packed_data=amp.dumps((sessid,kwargs))
+ ).addErrback(self.errback,command.key)
+ else:
+ # if no server connection is available, broadcast
+ returnself.broadcast(command,sessid,packed_data=amp.dumps((sessid,kwargs)))
+
+
[docs]defstart_server(self,server_twistd_cmd):
+ """
+ (Re-)Launch the Evennia server.
+
+ Args:
+ server_twisted_cmd (list): The server start instruction
+ to pass to POpen to start the server.
+
+ """
+ # start the Server
+ print("Portal starting server ... ")
+ process=None
+ withopen(settings.SERVER_LOG_FILE,"a")aslogfile:
+ # we link stdout to a file in order to catch
+ # eventual errors happening before the Server has
+ # opened its logger.
+ try:
+ if_is_windows():
+ # Windows requires special care
+ create_no_window=0x08000000
+ process=Popen(
+ server_twistd_cmd,
+ env=getenv(),
+ bufsize=-1,
+ stdout=logfile,
+ stderr=STDOUT,
+ creationflags=create_no_window,
+ )
+
+ else:
+ process=Popen(
+ server_twistd_cmd,env=getenv(),bufsize=-1,stdout=logfile,stderr=STDOUT
+ )
+ exceptException:
+ logger.log_trace()
+
+ self.factory.portal.server_twistd_cmd=server_twistd_cmd
+ logfile.flush()
+ ifprocessandnot_is_windows():
+ # avoid zombie-process on Unix/BSD
+ process.wait()
+ return
+
+
[docs]defwait_for_disconnect(self,callback,*args,**kwargs):
+ """
+ Add a callback for when this connection is lost.
+
+ Args:
+ callback (callable): Will be called with *args, **kwargs
+ once this protocol is disconnected.
+
+ """
+ self.factory.disconnect_callbacks[self]=(callback,args,kwargs)
+
+
[docs]defwait_for_server_connect(self,callback,*args,**kwargs):
+ """
+ Add a callback for when the Server is sure to have connected.
+
+ Args:
+ callback (callable): Will be called with *args, **kwargs
+ once the Server handshake with Portal is complete.
+
+ """
+ self.factory.server_connect_callbacks.append((callback,args,kwargs))
+
+
[docs]defstop_server(self,mode="shutdown"):
+ """
+ Shut down server in one or more modes.
+
+ Args:
+ mode (str): One of 'shutdown', 'reload' or 'reset'.
+
+ """
+ ifmode=="reload":
+ self.send_AdminPortal2Server(amp.DUMMYSESSION,operation=amp.SRELOAD)
+ elifmode=="reset":
+ self.send_AdminPortal2Server(amp.DUMMYSESSION,operation=amp.SRESET)
+ elifmode=="shutdown":
+ self.send_AdminPortal2Server(amp.DUMMYSESSION,operation=amp.SSHUTD)
+ self.factory.portal.server_restart_mode=mode
+
+ # sending amp data
+
+
[docs]defsend_Status2Launcher(self):
+ """
+ Send a status stanza to the launcher.
+
+ """
+ # print("send status to launcher")
+ # print("self.get_status(): {}".format(self.get_status()))
+ ifself.factory.launcher_connection:
+ self.factory.launcher_connection.callRemote(
+ amp.MsgStatus,status=amp.dumps(self.get_status())
+ ).addErrback(self.errback,amp.MsgStatus.key)
+
+
[docs]defsend_MsgPortal2Server(self,session,**kwargs):
+ """
+ Access method called by the Portal and executed on the Portal.
+
+ Args:
+ session (session): Session
+ kwargs (any, optional): Optional data.
+
+ Returns:
+ deferred (Deferred): Asynchronous return.
+
+ """
+ returnself.data_to_server(amp.MsgPortal2Server,session.sessid,**kwargs)
+
+
[docs]defsend_AdminPortal2Server(self,session,operation="",**kwargs):
+ """
+ Send Admin instructions from the Portal to the Server.
+ Executed on the Portal.
+
+ Args:
+ session (Session): Session.
+ operation (char, optional): Identifier for the server operation, as defined by the
+ global variables in `evennia/server/amp.py`.
+ data (str or dict, optional): Data used in the administrative operation.
+
+ """
+ returnself.data_to_server(
+ amp.AdminPortal2Server,session.sessid,operation=operation,**kwargs
+ )
+
+ # receive amp data
+
+ @amp.MsgStatus.responder
+ @amp.catch_traceback
+ defportal_receive_status(self,status):
+ """
+ Returns run-status for the server/portal.
+
+ Args:
+ status (str): Not used.
+ Returns:
+ status (dict): The status is a tuple
+ (portal_running, server_running, portal_pid, server_pid).
+
+ """
+ # print('Received PSTATUS request')
+ return{"status":amp.dumps(self.get_status())}
+
+ @amp.MsgLauncher2Portal.responder
+ @amp.catch_traceback
+ defportal_receive_launcher2portal(self,operation,arguments):
+ """
+ Receives message arriving from evennia_launcher.
+ This method is executed on the Portal.
+
+ Args:
+ operation (str): The action to perform.
+ arguments (str): Possible argument to the instruction, or the empty string.
+
+ Returns:
+ result (dict): The result back to the launcher.
+
+ Notes:
+ This is the entrypoint for controlling the entire Evennia system from the evennia
+ launcher. It can obviously only accessed when the Portal is already up and running.
+
+ """
+ # Since the launcher command uses amp.String() we need to convert from byte here.
+ operation=str(operation,"utf-8")
+ self.factory.launcher_connection=self
+ _,server_connected,_,_,_,_=self.get_status()
+
+ # logger.log_msg("Evennia Launcher->Portal operation %s:%s received" % (ord(operation), arguments))
+
+ # logger.log_msg("operation == amp.SSTART: {}: {}".format(operation == amp.SSTART, amp.loads(arguments)))
+
+ ifoperation==amp.SSTART:# portal start #15
+ # first, check if server is already running
+ ifnotserver_connected:
+ self.wait_for_server_connect(self.send_Status2Launcher)
+ self.start_server(amp.loads(arguments))
+
+ elifoperation==amp.SRELOAD:# reload server #14
+ ifserver_connected:
+ # We let the launcher restart us once they get the signal
+ self.factory.server_connection.wait_for_disconnect(self.send_Status2Launcher)
+ self.stop_server(mode="reload")
+ else:
+ self.wait_for_server_connect(self.send_Status2Launcher)
+ self.start_server(amp.loads(arguments))
+
+ elifoperation==amp.SRESET:# reload server #19
+ ifserver_connected:
+ self.factory.server_connection.wait_for_disconnect(self.send_Status2Launcher)
+ self.stop_server(mode="reset")
+ else:
+ self.wait_for_server_connect(self.send_Status2Launcher)
+ self.start_server(amp.loads(arguments))
+
+ elifoperation==amp.SSHUTD:# server-only shutdown #17
+ ifserver_connected:
+ self.factory.server_connection.wait_for_disconnect(self.send_Status2Launcher)
+ self.stop_server(mode="shutdown")
+
+ elifoperation==amp.PSHUTD:# portal + server shutdown #16
+ ifserver_connected:
+ self.factory.server_connection.wait_for_disconnect(self.factory.portal.shutdown)
+ else:
+ self.factory.portal.shutdown()
+
+ else:
+ logger.log_err("Operation {} not recognized".format(operation))
+ raiseException("operation %(op)s not recognized."%{"op":operation})
+
+ return{}
+
+ @amp.MsgServer2Portal.responder
+ @amp.catch_traceback
+ defportal_receive_server2portal(self,packed_data):
+ """
+ Receives message arriving to Portal from Server.
+ This method is executed on the Portal.
+
+ Args:
+ packed_data (str): Pickled data (sessid, kwargs) coming over the wire.
+
+ """
+ try:
+ sessid,kwargs=self.data_in(packed_data)
+ session=self.factory.portal.sessions.get(sessid,None)
+ ifsession:
+ self.factory.portal.sessions.data_out(session,**kwargs)
+ exceptException:
+ logger.log_trace("packed_data len {}".format(len(packed_data)))
+ return{}
+
+ @amp.AdminServer2Portal.responder
+ @amp.catch_traceback
+ defportal_receive_adminserver2portal(self,packed_data):
+ """
+
+ Receives and handles admin operations sent to the Portal
+ This is executed on the Portal.
+
+ Args:
+ packed_data (str): Data received, a pickled tuple (sessid, kwargs).
+
+ """
+ self.factory.server_connection=self
+
+ sessid,kwargs=self.data_in(packed_data)
+
+ # logger.log_msg("Evennia Server->Portal admin data %s:%s received" % (sessid, kwargs))
+
+ operation=kwargs.pop("operation")
+ portal_sessionhandler=self.factory.portal.sessions
+
+ ifoperation==amp.SLOGIN:# server_session_login
+ # a session has authenticated; sync it.
+ session=portal_sessionhandler.get(sessid)
+ ifsession:
+ portal_sessionhandler.server_logged_in(session,kwargs.get("sessiondata"))
+
+ elifoperation==amp.SDISCONN:# server_session_disconnect
+ # the server is ordering to disconnect the session
+ session=portal_sessionhandler.get(sessid)
+ ifsession:
+ portal_sessionhandler.server_disconnect(session,reason=kwargs.get("reason"))
+
+ elifoperation==amp.SDISCONNALL:# server_session_disconnect_all
+ # server orders all sessions to disconnect
+ portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason"))
+
+ elifoperation==amp.SRELOAD:# server reload
+ self.factory.server_connection.wait_for_disconnect(
+ self.start_server,self.factory.portal.server_twistd_cmd
+ )
+ self.stop_server(mode="reload")
+
+ elifoperation==amp.SRESET:# server reset
+ self.factory.server_connection.wait_for_disconnect(
+ self.start_server,self.factory.portal.server_twistd_cmd
+ )
+ self.stop_server(mode="reset")
+
+ elifoperation==amp.SSHUTD:# server-only shutdown
+ self.stop_server(mode="shutdown")
+
+ elifoperation==amp.PSHUTD:# full server+server shutdown
+ self.factory.server_connection.wait_for_disconnect(self.factory.portal.shutdown)
+ self.stop_server(mode="shutdown")
+
+ elifoperation==amp.PSYNC:# portal sync
+ # Server has (re-)connected and wants the session data from portal
+ self.factory.portal.server_info_dict=kwargs.get("info_dict",{})
+ self.factory.portal.server_process_id=kwargs.get("spid",None)
+ # this defaults to 'shutdown' or whatever value set in server_stop
+ server_restart_mode=self.factory.portal.server_restart_mode
+
+ sessdata=self.factory.portal.sessions.get_all_sync_data()
+ self.send_AdminPortal2Server(
+ amp.DUMMYSESSION,
+ amp.PSYNC,
+ server_restart_mode=server_restart_mode,
+ sessiondata=sessdata,
+ portal_start_time=self.factory.portal.start_time,
+ )
+ self.factory.portal.sessions.at_server_connection()
+
+ ifself.factory.server_connection:
+ # this is an indication the server has successfully connected, so
+ # we trigger any callbacks (usually to tell the launcher server is up)
+ forcallback,args,kwargsinself.factory.server_connect_callbacks:
+ try:
+ callback(*args,**kwargs)
+ exceptException:
+ logger.log_trace()
+ self.factory.server_connect_callbacks=[]
+
+ elifoperation==amp.SSYNC:# server_session_sync
+ # server wants to save session data to the portal,
+ # maybe because it's about to shut down.
+ portal_sessionhandler.server_session_sync(
+ kwargs.get("sessiondata"),kwargs.get("clean",True)
+ )
+
+ # set a flag in case we are about to shut down soon
+ self.factory.server_restart_mode=True
+
+ elifoperation==amp.SCONN:# server_force_connection (for irc/etc)
+ portal_sessionhandler.server_connect(**kwargs)
+
+ else:
+ raiseException("operation %(op)s not recognized."%{"op":operation})
+ return{}
+"""
+Grapevine network connection
+
+This is an implementation of the Grapevine Websocket protocol v 1.0.0 as
+outlined here: https://grapevine.haus/docs
+
+This will allow the linked game to transfer status as well as connects
+the grapevine client to in-game channels.
+
+"""
+
+importjson
+fromtwisted.internetimportprotocol
+fromdjango.confimportsettings
+fromevennia.server.sessionimportSession
+fromevennia.utilsimportget_evennia_version
+fromevennia.utils.loggerimportlog_info,log_err
+fromautobahn.twisted.websocketimportWebSocketClientProtocol,WebSocketClientFactory,connectWS
+
+# There is only one at this time
+GRAPEVINE_URI="wss://grapevine.haus/socket"
+
+GRAPEVINE_CLIENT_ID=settings.GRAPEVINE_CLIENT_ID
+GRAPEVINE_CLIENT_SECRET=settings.GRAPEVINE_CLIENT_SECRET
+GRAPEVINE_CHANNELS=settings.GRAPEVINE_CHANNELS
+
+# defined error codes
+CLOSE_NORMAL=1000
+GRAPEVINE_AUTH_ERROR=4000
+GRAPEVINE_HEARTBEAT_FAILURE=4001
+
+
+
[docs]classRestartingWebsocketServerFactory(WebSocketClientFactory,protocol.ReconnectingClientFactory):
+ """
+ A variant of the websocket-factory that auto-reconnects.
+
+ """
+
+ initialDelay=1
+ factor=1.5
+ maxDelay=60
+
+
[docs]defbuildProtocol(self,addr):
+ """
+ Build new instance of protocol
+
+ Args:
+ addr (str): Not used, using factory/settings data
+
+ """
+ protocol=GrapevineClient()
+ protocol.factory=self
+ protocol.channel=self.channel
+ protocol.sessionhandler=self.sessionhandler
+ returnprotocol
+
+
[docs]defstartedConnecting(self,connector):
+ """
+ Tracks reconnections for debugging.
+
+ Args:
+ connector (Connector): Represents the connection.
+
+ """
+ log_info("(re)connecting to grapevine channel '%s'"%self.channel)
+
+
[docs]defclientConnectionFailed(self,connector,reason):
+ """
+ Called when Client failed to connect.
+
+ Args:
+ connector (Connection): Represents the connection.
+ reason (str): The reason for the failure.
+
+ """
+ protocol.ReconnectingClientFactory.clientConnectionLost(self,connector,reason)
+
+
[docs]defclientConnectionLost(self,connector,reason):
+ """
+ Called when Client loses connection.
+
+ Args:
+ connector (Connection): Represents the connection.
+ reason (str): The reason for the failure.
+
+ """
+ ifnot(self.botor(self.botandself.bot.stopping)):
+ self.retry(connector)
+
+
[docs]defreconnect(self):
+ """
+ Force a reconnection of the bot protocol. This requires
+ de-registering the session and then reattaching a new one,
+ otherwise you end up with an ever growing number of bot
+ sessions.
+
+ """
+ self.bot.stopping=True
+ self.bot.transport.loseConnection()
+ self.sessionhandler.server_disconnect(self.bot)
+ self.start()
+
+
[docs]defstart(self):
+ "Connect protocol to remote server"
+
+ try:
+ fromtwisted.internetimportssl
+ exceptImportError:
+ log_err("To use Grapevine, The PyOpenSSL module must be installed.")
+ else:
+ context_factory=ssl.ClientContextFactory()ifself.isSecureelseNone
+ connectWS(self,context_factory)
[docs]defonMessage(self,payload,isBinary):
+ """
+ Callback fired when a complete WebSocket message was received.
+
+ Args:
+ payload (bytes): The WebSocket message received.
+ isBinary (bool): Flag indicating whether payload is binary or
+ UTF-8 encoded text.
+
+ """
+ ifnotisBinary:
+ data=json.loads(str(payload,"utf-8"))
+ self.data_in(data=data)
+ self.retry_task=None
+
+
[docs]defonClose(self,wasClean,code=None,reason=None):
+ """
+ This is executed when the connection is lost for whatever
+ reason. it can also be called directly, from the disconnect
+ method.
+
+ Args:
+ wasClean (bool): ``True`` if the WebSocket was closed cleanly.
+ code (int or None): Close status as sent by the WebSocket peer.
+ reason (str or None): Close reason as sent by the WebSocket peer.
+
+ """
+ self.disconnect(reason)
+
+ ifcode==GRAPEVINE_HEARTBEAT_FAILURE:
+ log_err("Grapevine connection lost (Heartbeat error)")
+ elifcode==GRAPEVINE_AUTH_ERROR:
+ log_err("Grapevine connection lost (Auth error)")
+ elifself.restart_downtime:
+ # server previously warned us about downtime and told us to be
+ # ready to reconnect.
+ log_info("Grapevine connection lost (Server restart).")
+
+ def_send_json(self,data):
+ """
+ Send (json-) data to client.
+
+ Args:
+ data (str): Text to send.
+
+ """
+ returnself.sendMessage(json.dumps(data).encode("utf-8"))
+
+
[docs]defdisconnect(self,reason=None):
+ """
+ Generic hook for the engine to call in order to
+ disconnect this protocol.
+
+ Args:
+ reason (str or None): Motivation for the disconnection.
+
+ """
+ self.sessionhandler.disconnect(self)
+ # autobahn-python: 1000 for a normal close, 3000-4999 for app. specific,
+ # in case anyone wants to expose this functionality later.
+ #
+ # sendClose() under autobahn/websocket/interfaces.py
+ self.sendClose(CLOSE_NORMAL,reason)
+
+ # send_* method are automatically callable through .msg(heartbeat={}) etc
+
+
[docs]defsend_subscribe(self,channelname,*args,**kwargs):
+ """
+ Subscribe to new grapevine channel
+
+ Use with session.msg(subscribe="channelname")
+ """
+ data={"event":"channels/subscribe","payload":{"channel":channelname}}
+ self._send_json(data)
+
+
[docs]defsend_unsubscribe(self,channelname,*args,**kwargs):
+ """
+ Un-subscribe to a grapevine channel
+
+ Use with session.msg(unsubscribe="channelname")
+ """
+ data={"event":"channels/unsubscribe","payload":{"channel":channelname}}
+ self._send_json(data)
+
+
[docs]defsend_channel(self,text,channel,sender,*args,**kwargs):
+ """
+ Send text type Evennia -> grapevine
+
+ This is the channels/send message type
+
+ Use with session.msg(channel=(message, channel, sender))
+
+ """
+
+ data={
+ "event":"channels/send",
+ "payload":{"message":text,"channel":channel,"name":sender},
+ }
+ self._send_json(data)
[docs]defparse_irc_to_ansi(string):
+ """
+ Parse IRC mIRC color syntax and replace with Evennia ANSI color markers
+
+ Args:
+ string (str): String to parse for IRC colors.
+
+ Returns:
+ parsed_string (str): String with replaced IRC colors.
+
+ """
+
+ def_sub_to_ansi(irc_match):
+ returnANSI_COLOR_MAP.get(irc_match.group(),"")
+
+ in_string=utils.to_str(string)
+ pstring=RE_IRC_COLOR.sub(_sub_to_ansi,in_string)
+ returnpstring
+
+
+# IRC bot
+
+
+
[docs]classIRCBot(irc.IRCClient,Session):
+ """
+ An IRC bot that tracks activity in a channel as well
+ as sends text to it when prompted
+
+ """
+
+ lineRate=1
+
+ # assigned by factory at creation
+
+ nickname=None
+ logger=None
+ factory=None
+ channel=None
+ sourceURL="http://code.evennia.com"
+
+
[docs]defsignedOn(self):
+ """
+ This is called when we successfully connect to the network. We
+ make sure to now register with the game as a full session.
+
+ """
+ self.join(self.channel)
+ self.stopping=False
+ self.factory.bot=self
+ address="%s@%s"%(self.channel,self.network)
+ self.init_session("ircbot",address,self.factory.sessionhandler)
+ # we link back to our bot and log in
+ self.uid=int(self.factory.uid)
+ self.logged_in=True
+ self.factory.sessionhandler.connect(self)
+ logger.log_info(
+ "IRC bot '%s' connected to %s at %s:%s."
+ %(self.nickname,self.channel,self.network,self.port)
+ )
+
+
[docs]defdisconnect(self,reason=""):
+ """
+ Called by sessionhandler to disconnect this protocol.
+
+ Args:
+ reason (str): Motivation for the disconnect.
+
+ """
+ self.sessionhandler.disconnect(self)
+ self.stopping=True
+ self.transport.loseConnection()
[docs]defprivmsg(self,user,channel,msg):
+ """
+ Called when the connected channel receives a message.
+
+ Args:
+ user (str): User name sending the message.
+ channel (str): Channel name seeing the message.
+ msg (str): The message arriving from channel.
+
+ """
+ ifchannel==self.nickname:
+ # private message
+ user=user.split("!",1)[0]
+ self.data_in(text=msg,type="privmsg",user=user,channel=channel)
+ elifnotmsg.startswith("***"):
+ # channel message
+ user=user.split("!",1)[0]
+ user=ansi.raw(user)
+ self.data_in(text=msg,type="msg",user=user,channel=channel)
+
+
[docs]defaction(self,user,channel,msg):
+ """
+ Called when an action is detected in channel.
+
+ Args:
+ user (str): User name sending the message.
+ channel (str): Channel name seeing the message.
+ msg (str): The message arriving from channel.
+
+ """
+ ifnotmsg.startswith("**"):
+ user=user.split("!",1)[0]
+ self.data_in(text=msg,type="action",user=user,channel=channel)
+
+
[docs]defget_nicklist(self):
+ """
+ Retrieve name list from the channel. The return
+ is handled by the catch methods below.
+
+ """
+ ifnotself.nicklist:
+ self.sendLine("NAMES %s"%self.channel)
[docs]defirc_RPL_ENDOFNAMES(self,prefix,params):
+ """Called when the nicklist has finished being returned."""
+ channel=params[1].lower()
+ ifchannel!=self.channel.lower():
+ return
+ self.data_in(
+ text="",type="nicklist",user="server",channel=channel,nicklist=self.nicklist
+ )
+ self.nicklist=[]
+
+
[docs]defpong(self,user,time):
+ """
+ Called with the return timing from a PING.
+
+ Args:
+ user (str): Name of user
+ time (float): Ping time in secs.
+
+ """
+ self.data_in(text="",type="ping",user="server",channel=self.channel,timing=time)
+
+
[docs]defdata_in(self,text=None,**kwargs):
+ """
+ Data IRC -> Server.
+
+ Keyword Args:
+ text (str): Ingoing text.
+ kwargs (any): Other data from protocol.
+
+ """
+ self.sessionhandler.data_in(self,bot_data_in=[parse_irc_to_ansi(text),kwargs])
+
+
[docs]defsend_channel(self,*args,**kwargs):
+ """
+ Send channel text to IRC channel (visible to all). Note that
+ we don't handle the "text" send (it's rerouted to send_default
+ which does nothing) - this is because the IRC bot is a normal
+ session and would otherwise report anything that happens to it
+ to the IRC channel (such as it seeing server reload messages).
+
+ Args:
+ text (str): Outgoing text
+
+ """
+ text=args[0]ifargselse""
+ iftext:
+ text=parse_ansi_to_irc(text)
+ self.say(self.channel,text)
+
+
[docs]defsend_privmsg(self,*args,**kwargs):
+ """
+ Send message only to specific user.
+
+ Args:
+ text (str): Outgoing text.
+
+ Keyword Args:
+ user (str): the nick to send
+ privately to.
+
+ """
+ text=args[0]ifargselse""
+ user=kwargs.get("user",None)
+ iftextanduser:
+ text=parse_ansi_to_irc(text)
+ self.msg(user,text)
+
+
[docs]defsend_request_nicklist(self,*args,**kwargs):
+ """
+ Send a request for the channel nicklist. The return (handled
+ by `self.irc_RPL_ENDOFNAMES`) will be sent back as a message
+ with type `nicklist'.
+ """
+ self.get_nicklist()
+
+
[docs]defsend_ping(self,*args,**kwargs):
+ """
+ Send a ping. The return (handled by `self.pong`) will be sent
+ back as a message of type 'ping'.
+ """
+ self.ping(self.nickname)
+
+
[docs]defsend_reconnect(self,*args,**kwargs):
+ """
+ The server instructs us to rebuild the connection by force,
+ probably because the client silently lost connection.
+ """
+ self.factory.reconnect()
+
+
[docs]defsend_default(self,*args,**kwargs):
+ """
+ Ignore other types of sends.
+
+ """
+ pass
+
+
+
[docs]classIRCBotFactory(protocol.ReconnectingClientFactory):
+ """
+ Creates instances of IRCBot, connecting with a staggered
+ increase in delay
+
+ """
+
+ # scaling reconnect time
+ initialDelay=1
+ factor=1.5
+ maxDelay=60
+
+
[docs]def__init__(
+ self,
+ sessionhandler,
+ uid=None,
+ botname=None,
+ channel=None,
+ network=None,
+ port=None,
+ ssl=None,
+ ):
+ """
+ Storing some important protocol properties.
+
+ Args:
+ sessionhandler (SessionHandler): Reference to the main Sessionhandler.
+
+ Keyword Args:
+ uid (int): Bot user id.
+ botname (str): Bot name (seen in IRC channel).
+ channel (str): IRC channel to connect to.
+ network (str): Network address to connect to.
+ port (str): Port of the network.
+ ssl (bool): Indicates SSL connection.
+
+ """
+ self.sessionhandler=sessionhandler
+ self.uid=uid
+ self.nickname=str(botname)
+ self.channel=str(channel)
+ self.network=str(network)
+ self.port=port
+ self.ssl=ssl
+ self.bot=None
+ self.nicklists={}
+
+
[docs]defbuildProtocol(self,addr):
+ """
+ Build the protocol and assign it some properties.
+
+ Args:
+ addr (str): Not used; using factory data.
+
+ """
+ protocol=IRCBot()
+ protocol.factory=self
+ protocol.nickname=self.nickname
+ protocol.channel=self.channel
+ protocol.network=self.network
+ protocol.port=self.port
+ protocol.ssl=self.ssl
+ protocol.nicklist=[]
+ returnprotocol
+
+
[docs]defstartedConnecting(self,connector):
+ """
+ Tracks reconnections for debugging.
+
+ Args:
+ connector (Connector): Represents the connection.
+
+ """
+ logger.log_info("(re)connecting to %s"%self.channel)
+
+
[docs]defclientConnectionFailed(self,connector,reason):
+ """
+ Called when Client failed to connect.
+
+ Args:
+ connector (Connection): Represents the connection.
+ reason (str): The reason for the failure.
+
+ """
+ self.retry(connector)
+
+
[docs]defclientConnectionLost(self,connector,reason):
+ """
+ Called when Client loses connection.
+
+ Args:
+ connector (Connection): Represents the connection.
+ reason (str): The reason for the failure.
+
+ """
+ ifnot(self.botor(self.botandself.bot.stopping)):
+ self.retry(connector)
+
+
[docs]defreconnect(self):
+ """
+ Force a reconnection of the bot protocol. This requires
+ de-registering the session and then reattaching a new one,
+ otherwise you end up with an ever growing number of bot
+ sessions.
+
+ """
+ self.bot.stopping=True
+ self.bot.transport.loseConnection()
+ self.sessionhandler.server_disconnect(self.bot)
+ self.start()
+
+
[docs]defstart(self):
+ """
+ Connect session to sessionhandler.
+
+ """
+ ifself.port:
+ ifself.ssl:
+ try:
+ fromtwisted.internetimportssl
+
+ service=reactor.connectSSL(
+ self.network,int(self.port),self,ssl.ClientContextFactory()
+ )
+ exceptImportError:
+ logger.log_err("To use SSL, the PyOpenSSL module must be installed.")
+ else:
+ service=internet.TCPClient(self.network,int(self.port),self)
+ self.sessionhandler.portal.services.addService(service)
+"""
+
+MCCP - Mud Client Compression Protocol
+
+This implements the MCCP v2 telnet protocol as per
+http://tintin.sourceforge.net/mccp/. MCCP allows for the server to
+compress data when sending to supporting clients, reducing bandwidth
+by 70-90%.. The compression is done using Python's builtin zlib
+library. If the client doesn't support MCCP, server sends uncompressed
+as normal. Note: On modern hardware you are not likely to notice the
+effect of MCCP unless you have extremely heavy traffic or sits on a
+terribly slow connection.
+
+This protocol is implemented by the telnet protocol importing
+mccp_compress and calling it from its write methods.
+"""
+importzlib
+
+# negotiations for v1 and v2 of the protocol
+MCCP=bytes([86])# b"\x56"
+FLUSH=zlib.Z_SYNC_FLUSH
+
+
+
[docs]defmccp_compress(protocol,data):
+ """
+ Handles zlib compression, if applicable.
+
+ Args:
+ data (str): Incoming data to compress.
+
+ Returns:
+ stream (binary): Zlib-compressed data.
+
+ """
+ ifhasattr(protocol,"zlib"):
+ returnprotocol.zlib.compress(data)+protocol.zlib.flush(FLUSH)
+ returndata
+
+
+
[docs]classMccp(object):
+ """
+ Implements the MCCP protocol. Add this to a
+ variable on the telnet protocol to set it up.
+
+ """
+
+
[docs]def__init__(self,protocol):
+ """
+ initialize MCCP by storing protocol on
+ ourselves and calling the client to see if
+ it supports MCCP. Sets callbacks to
+ start zlib compression in that case.
+
+ Args:
+ protocol (Protocol): The active protocol instance.
+
+ """
+
+ self.protocol=protocol
+ self.protocol.protocol_flags["MCCP"]=False
+ # ask if client will mccp, connect callbacks to handle answer
+ self.protocol.will(MCCP).addCallbacks(self.do_mccp,self.no_mccp)
+
+
[docs]defno_mccp(self,option):
+ """
+ Called if client doesn't support mccp or chooses to turn it off.
+
+ Args:
+ option (Option): Option dict (not used).
+
+ """
+ ifhasattr(self.protocol,"zlib"):
+ delself.protocol.zlib
+ self.protocol.protocol_flags["MCCP"]=False
+ self.protocol.handshake_done()
+
+
[docs]defdo_mccp(self,option):
+ """
+ The client supports MCCP. Set things up by
+ creating a zlib compression stream.
+
+ Args:
+ option (Option): Option dict (not used).
+
+ """
+ self.protocol.protocol_flags["MCCP"]=True
+ self.protocol.requestNegotiation(MCCP,b"")
+ self.protocol.zlib=zlib.compressobj(9)
+ self.protocol.handshake_done()
+"""
+
+MSSP - Mud Server Status Protocol
+
+This implements the MSSP telnet protocol as per
+http://tintin.sourceforge.net/mssp/. MSSP allows web portals and
+listings to have their crawlers find the mud and automatically
+extract relevant information about it, such as genre, how many
+active players and so on.
+
+
+"""
+fromdjango.confimportsettings
+fromevennia.utilsimportutils
+
+MSSP=bytes([70])# b"\x46"
+MSSP_VAR=bytes([1])# b"\x01"
+MSSP_VAL=bytes([2])# b"\x02"
+
+# try to get the customized mssp info, if it exists.
+MSSPTable_CUSTOM=utils.variable_from_module(settings.MSSP_META_MODULE,"MSSPTable",default={})
+
+
+
[docs]classMssp(object):
+ """
+ Implements the MSSP protocol. Add this to a variable on the telnet
+ protocol to set it up.
+
+ """
+
+
[docs]def__init__(self,protocol):
+ """
+ initialize MSSP by storing protocol on ourselves and calling
+ the client to see if it supports MSSP.
+
+ Args:
+ protocol (Protocol): The active protocol instance.
+
+ """
+ self.protocol=protocol
+ self.protocol.will(MSSP).addCallbacks(self.do_mssp,self.no_mssp)
+
+
[docs]defget_player_count(self):
+ """
+ Get number of logged-in players.
+
+ Returns:
+ count (int): The number of players in the MUD.
+
+ """
+ returnstr(self.protocol.sessionhandler.count_loggedin())
+
+
[docs]defget_uptime(self):
+ """
+ Get how long the portal has been online (reloads are not counted).
+
+ Returns:
+ uptime (int): Number of seconds of uptime.
+
+ """
+ returnstr(self.protocol.sessionhandler.uptime)
+
+
[docs]defno_mssp(self,option):
+ """
+ Called when mssp is not requested. This is the normal
+ operation.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.protocol.handshake_done()
+
+
[docs]defdo_mssp(self,option):
+ """
+ Negotiate all the information.
+
+ Args:
+ option (Option): Not used.
+
+ """
+
+ self.mssp_table={
+ # Required fields
+ "NAME":settings.SERVERNAME,
+ "PLAYERS":self.get_player_count,
+ "UPTIME":self.get_uptime,
+ "PORT":list(
+ str(port)forportinreversed(settings.TELNET_PORTS)
+ ),# most important port should be last in list
+ # Evennia auto-filled
+ "CRAWL DELAY":"-1",
+ "CODEBASE":utils.get_evennia_version(mode="pretty"),
+ "FAMILY":"Custom",
+ "ANSI":"1",
+ "GMCP":"1"ifsettings.TELNET_OOB_ENABLEDelse"0",
+ "ATCP":"0",
+ "MCCP":"1",
+ "MCP":"0",
+ "MSDP":"1"ifsettings.TELNET_OOB_ENABLEDelse"0",
+ "MSP":"0",
+ "MXP":"1",
+ "PUEBLO":"0",
+ "SSL":"1"ifsettings.SSL_ENABLEDelse"0",
+ "UTF-8":"1",
+ "ZMP":"0",
+ "VT100":"1",
+ "XTERM 256 COLORS":"1",
+ }
+
+ # update the static table with the custom one
+ ifMSSPTable_CUSTOM:
+ self.mssp_table.update(MSSPTable_CUSTOM)
+
+ varlist=b""
+ forvariable,valueinself.mssp_table.items():
+ ifcallable(value):
+ value=value()
+ ifutils.is_iter(value):
+ forpartvalinvalue:
+ varlist+=(
+ MSSP_VAR
+ +bytes(str(variable),"utf-8")
+ +MSSP_VAL
+ +bytes(str(partval),"utf-8")
+ )
+ else:
+ varlist+=(
+ MSSP_VAR+bytes(str(variable),"utf-8")+MSSP_VAL+bytes(str(value),"utf-8")
+ )
+
+ # send to crawler by subnegotiation
+ self.protocol.requestNegotiation(MSSP,varlist)
+ self.protocol.handshake_done()
+"""
+MXP - Mud eXtension Protocol.
+
+Partial implementation of the MXP protocol.
+The MXP protocol allows more advanced formatting options for telnet clients
+that supports it (mudlet, zmud, mushclient are a few)
+
+This only implements the SEND tag.
+
+More information can be found on the following links:
+http://www.zuggsoft.com/zmud/mxp.htm
+http://www.mushclient.com/mushclient/mxp.htm
+http://www.gammon.com.au/mushclient/addingservermxp.htm
+
+"""
+importre
+
+LINKS_SUB=re.compile(r"\|lc(.*?)\|lt(.*?)\|le",re.DOTALL)
+
+# MXP Telnet option
+MXP=bytes([91])# b"\x5b"
+
+MXP_TEMPSECURE="\x1B[4z"
+MXP_SEND=MXP_TEMPSECURE+'<SEND HREF="\\1">'+"\\2"+MXP_TEMPSECURE+"</SEND>"
+
+
+
[docs]defmxp_parse(text):
+ """
+ Replaces links to the correct format for MXP.
+
+ Args:
+ text (str): The text to parse.
+
+ Returns:
+ parsed (str): The parsed text.
+
+ """
+ text=text.replace("&","&").replace("<","<").replace(">",">")
+
+ text=LINKS_SUB.sub(MXP_SEND,text)
+ returntext
[docs]def__init__(self,protocol):
+ """
+ Initializes the protocol by checking if the client supports it.
+
+ Args:
+ protocol (Protocol): The active protocol instance.
+
+ """
+ self.protocol=protocol
+ self.protocol.protocol_flags["MXP"]=False
+ self.protocol.will(MXP).addCallbacks(self.do_mxp,self.no_mxp)
+
+
[docs]defno_mxp(self,option):
+ """
+ Called when the Client reports to not support MXP.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.protocol.protocol_flags["MXP"]=False
+ self.protocol.handshake_done()
+
+
[docs]defdo_mxp(self,option):
+ """
+ Called when the Client reports to support MXP.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.protocol.protocol_flags["MXP"]=True
+ self.protocol.requestNegotiation(MXP,b"")
+ self.protocol.handshake_done()
+"""
+
+NAWS - Negotiate About Window Size
+
+This implements the NAWS telnet option as per
+https://www.ietf.org/rfc/rfc1073.txt
+
+NAWS allows telnet clients to report their current window size to the
+client and update it when the size changes
+
+"""
+fromcodecsimportencodeascodecs_encode
+fromdjango.confimportsettings
+
+NAWS=bytes([31])# b"\x1f"
+IS=bytes([0])# b"\x00"
+# default taken from telnet specification
+DEFAULT_WIDTH=settings.CLIENT_DEFAULT_WIDTH
+DEFAULT_HEIGHT=settings.CLIENT_DEFAULT_HEIGHT
+
+# try to get the customized mssp info, if it exists.
+
+
+
[docs]classNaws(object):
+ """
+ Implements the NAWS protocol. Add this to a variable on the telnet
+ protocol to set it up.
+
+ """
+
+
[docs]def__init__(self,protocol):
+ """
+ initialize NAWS by storing protocol on ourselves and calling
+ the client to see if it supports NAWS.
+
+ Args:
+ protocol (Protocol): The active protocol instance.
+
+ """
+ self.naws_step=0
+ self.protocol=protocol
+ self.protocol.protocol_flags["SCREENWIDTH"]={
+ 0:DEFAULT_WIDTH
+ }# windowID (0 is root):width
+ self.protocol.protocol_flags["SCREENHEIGHT"]={0:DEFAULT_HEIGHT}# windowID:width
+ self.protocol.negotiationMap[NAWS]=self.negotiate_sizes
+ self.protocol.do(NAWS).addCallbacks(self.do_naws,self.no_naws)
+
+
[docs]defno_naws(self,option):
+ """
+ Called when client is not reporting NAWS. This is the normal
+ operation.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.protocol.handshake_done()
+
+
[docs]defdo_naws(self,option):
+ """
+ Client wants to negotiate all the NAWS information.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.protocol.handshake_done()
+
+
[docs]defnegotiate_sizes(self,options):
+ """
+ Step through the NAWS handshake.
+
+ Args:
+ option (list): The incoming NAWS options.
+
+ """
+ iflen(options)==4:
+ # NAWS is negotiated with 16bit words
+ width=options[0]+options[1]
+ self.protocol.protocol_flags["SCREENWIDTH"][0]=int(codecs_encode(width,"hex"),16)
+ height=options[2]+options[3]
+ self.protocol.protocol_flags["SCREENHEIGHT"][0]=int(codecs_encode(height,"hex"),16)
+"""
+This module implements the main Evennia server process, the core of
+the game engine.
+
+This module should be started with the 'twistd' executable since it
+sets up all the networking features. (this is done automatically
+by game/evennia.py).
+
+"""
+importsys
+importos
+importtime
+
+fromos.pathimportdirname,abspath
+fromtwisted.applicationimportinternet,service
+fromtwisted.internet.taskimportLoopingCall
+fromtwisted.internetimportprotocol,reactor
+fromtwisted.python.logimportILogObserver
+
+importdjango
+
+django.setup()
+fromdjango.confimportsettings
+fromdjango.dbimportconnection
+
+importevennia
+
+evennia._init()
+
+fromevennia.utils.utilsimportget_evennia_version,mod_import,make_iter
+fromevennia.server.portal.portalsessionhandlerimportPORTAL_SESSIONS
+fromevennia.utilsimportlogger
+fromevennia.server.webserverimportEvenniaReverseProxyResource
+
+
+# we don't need a connection to the database so close it right away
+try:
+ connection.close()
+exceptException:
+ pass
+
+PORTAL_SERVICES_PLUGIN_MODULES=[
+ mod_import(module)formoduleinmake_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES)
+]
+LOCKDOWN_MODE=settings.LOCKDOWN_MODE
+
+# -------------------------------------------------------------
+# Evennia Portal settings
+# -------------------------------------------------------------
+
+VERSION=get_evennia_version()
+
+SERVERNAME=settings.SERVERNAME
+
+PORTAL_RESTART=os.path.join(settings.GAME_DIR,"server","portal.restart")
+
+TELNET_PORTS=settings.TELNET_PORTS
+SSL_PORTS=settings.SSL_PORTS
+SSH_PORTS=settings.SSH_PORTS
+WEBSERVER_PORTS=settings.WEBSERVER_PORTS
+WEBSOCKET_CLIENT_PORT=settings.WEBSOCKET_CLIENT_PORT
+
+TELNET_INTERFACES=["127.0.0.1"]ifLOCKDOWN_MODEelsesettings.TELNET_INTERFACES
+SSL_INTERFACES=["127.0.0.1"]ifLOCKDOWN_MODEelsesettings.SSL_INTERFACES
+SSH_INTERFACES=["127.0.0.1"]ifLOCKDOWN_MODEelsesettings.SSH_INTERFACES
+WEBSERVER_INTERFACES=["127.0.0.1"]ifLOCKDOWN_MODEelsesettings.WEBSERVER_INTERFACES
+WEBSOCKET_CLIENT_INTERFACE="127.0.0.1"ifLOCKDOWN_MODEelsesettings.WEBSOCKET_CLIENT_INTERFACE
+WEBSOCKET_CLIENT_URL=settings.WEBSOCKET_CLIENT_URL
+
+TELNET_ENABLED=settings.TELNET_ENABLEDandTELNET_PORTSandTELNET_INTERFACES
+SSL_ENABLED=settings.SSL_ENABLEDandSSL_PORTSandSSL_INTERFACES
+SSH_ENABLED=settings.SSH_ENABLEDandSSH_PORTSandSSH_INTERFACES
+WEBSERVER_ENABLED=settings.WEBSERVER_ENABLEDandWEBSERVER_PORTSandWEBSERVER_INTERFACES
+WEBCLIENT_ENABLED=settings.WEBCLIENT_ENABLED
+WEBSOCKET_CLIENT_ENABLED=(
+ settings.WEBSOCKET_CLIENT_ENABLEDandWEBSOCKET_CLIENT_PORTandWEBSOCKET_CLIENT_INTERFACE
+)
+
+AMP_HOST=settings.AMP_HOST
+AMP_PORT=settings.AMP_PORT
+AMP_INTERFACE=settings.AMP_INTERFACE
+AMP_ENABLED=AMP_HOSTandAMP_PORTandAMP_INTERFACE
+
+INFO_DICT={
+ "servername":SERVERNAME,
+ "version":VERSION,
+ "errors":"",
+ "info":"",
+ "lockdown_mode":"",
+ "amp":"",
+ "telnet":[],
+ "telnet_ssl":[],
+ "ssh":[],
+ "webclient":[],
+ "webserver_proxy":[],
+ "webserver_internal":[],
+}
+
+try:
+ WEB_PLUGINS_MODULE=mod_import(settings.WEB_PLUGINS_MODULE)
+exceptImportError:
+ WEB_PLUGINS_MODULE=None
+ INFO_DICT["errors"]=(
+ "WARNING: settings.WEB_PLUGINS_MODULE not found - "
+ "copy 'evennia/game_template/server/conf/web_plugins.py to "
+ "mygame/server/conf."
+ )
+
+
+_MAINTENANCE_COUNT=0
+
+
+def_portal_maintenance():
+ """
+ Repeated maintenance tasks for the portal.
+
+ """
+ global_MAINTENANCE_COUNT
+
+ _MAINTENANCE_COUNT+=1
+
+ if_MAINTENANCE_COUNT%(60*7)==0:
+ # drop database connection every 7 hrs to avoid default timeouts on MySQL
+ # (see https://github.com/evennia/evennia/issues/1376)
+ connection.close()
+
+
+# -------------------------------------------------------------
+# Portal Service object
+# -------------------------------------------------------------
+
+
+
[docs]classPortal(object):
+
+ """
+ The main Portal server handler. This object sets up the database
+ and tracks and interlinks all the twisted network services that
+ make up Portal.
+
+ """
+
+
[docs]def__init__(self,application):
+ """
+ Setup the server.
+
+ Args:
+ application (Application): An instantiated Twisted application
+
+ """
+ sys.path.append(".")
+
+ # create a store of services
+ self.services=service.MultiService()
+ self.services.setServiceParent(application)
+ self.amp_protocol=None# set by amp factory
+ self.sessions=PORTAL_SESSIONS
+ self.sessions.portal=self
+ self.process_id=os.getpid()
+
+ self.server_process_id=None
+ self.server_restart_mode="shutdown"
+ self.server_info_dict={}
+
+ self.start_time=time.time()
+
+ self.maintenance_task=LoopingCall(_portal_maintenance)
+ self.maintenance_task.start(60,now=True)# call every minute
+
+ # in non-interactive portal mode, this gets overwritten by
+ # cmdline sent by the evennia launcher
+ self.server_twistd_cmd=self._get_backup_server_twistd_cmd()
+
+ # set a callback if the server is killed abruptly,
+ # by Ctrl-C, reboot etc.
+ reactor.addSystemEventTrigger(
+ "before","shutdown",self.shutdown,_reactor_stopping=True,_stop_server=True
+ )
+
+ def_get_backup_server_twistd_cmd(self):
+ """
+ For interactive Portal mode there is no way to get the server cmdline from the launcher, so
+ we need to guess it here (it's very likely to not change)
+
+ Returns:
+ server_twistd_cmd (list): An instruction for starting the server, to pass to Popen.
+ """
+ server_twistd_cmd=[
+ "twistd",
+ "--python={}".format(os.path.join(dirname(dirname(abspath(__file__))),"server.py")),
+ ]
+ ifos.name!="nt":
+ gamedir=os.getcwd()
+ server_twistd_cmd.append(
+ "--pidfile={}".format(os.path.join(gamedir,"server","server.pid"))
+ )
+ returnserver_twistd_cmd
+
+
[docs]defget_info_dict(self):
+ "Return the Portal info, for display."
+ returnINFO_DICT
+
+
[docs]defshutdown(self,_reactor_stopping=False,_stop_server=False):
+ """
+ Shuts down the server from inside it.
+
+ Args:
+ _reactor_stopping (bool, optional): This is set if server
+ is already in the process of shutting down; in this case
+ we don't need to stop it again.
+ _stop_server (bool, optional): Only used in portal-interactive mode;
+ makes sure to stop the Server cleanly.
+
+ Note that restarting (regardless of the setting) will not work
+ if the Portal is currently running in daemon mode. In that
+ case it always needs to be restarted manually.
+
+ """
+ if_reactor_stoppingandhasattr(self,"shutdown_complete"):
+ # we get here due to us calling reactor.stop below. No need
+ # to do the shutdown procedure again.
+ return
+
+ self.sessions.disconnect_all()
+ if_stop_server:
+ self.amp_protocol.stop_server(mode="shutdown")
+ ifnot_reactor_stopping:
+ # shutting down the reactor will trigger another signal. We set
+ # a flag to avoid loops.
+ self.shutdown_complete=True
+ reactor.callLater(0,reactor.stop)
+
+
+# -------------------------------------------------------------
+#
+# Start the Portal proxy server and add all active services
+#
+# -------------------------------------------------------------
+
+
+# twistd requires us to define the variable 'application' so it knows
+# what to execute from.
+application=service.Application("Portal")
+
+# custom logging
+
+if"--nodaemon"notinsys.argv:
+ logfile=logger.WeeklyLogFile(
+ os.path.basename(settings.PORTAL_LOG_FILE),
+ os.path.dirname(settings.PORTAL_LOG_FILE),
+ day_rotation=settings.PORTAL_LOG_DAY_ROTATION,
+ max_size=settings.PORTAL_LOG_MAX_SIZE,
+ )
+ application.setComponent(ILogObserver,logger.PortalLogObserver(logfile).emit)
+
+# The main Portal server program. This sets up the database
+# and is where we store all the other services.
+PORTAL=Portal(application)
+
+ifLOCKDOWN_MODE:
+
+ INFO_DICT["lockdown_mode"]=" LOCKDOWN_MODE active: Only local connections."
+
+ifAMP_ENABLED:
+
+ # The AMP protocol handles the communication between
+ # the portal and the mud server. Only reason to ever deactivate
+ # it would be during testing and debugging.
+
+ fromevennia.server.portalimportamp_server
+
+ INFO_DICT["amp"]="amp: %s"%AMP_PORT
+
+ factory=amp_server.AMPServerFactory(PORTAL)
+ amp_service=internet.TCPServer(AMP_PORT,factory,interface=AMP_INTERFACE)
+ amp_service.setName("PortalAMPServer")
+ PORTAL.services.addService(amp_service)
+
+
+# We group all the various services under the same twisted app.
+# These will gradually be started as they are initialized below.
+
+ifTELNET_ENABLED:
+
+ # Start telnet game connections
+
+ fromevennia.server.portalimporttelnet
+
+ forinterfaceinTELNET_INTERFACES:
+ ifacestr=""
+ ifinterfacenotin("0.0.0.0","::")orlen(TELNET_INTERFACES)>1:
+ ifacestr="-%s"%interface
+ forportinTELNET_PORTS:
+ pstring="%s:%s"%(ifacestr,port)
+ factory=telnet.TelnetServerFactory()
+ factory.noisy=False
+ factory.protocol=telnet.TelnetProtocol
+ factory.sessionhandler=PORTAL_SESSIONS
+ telnet_service=internet.TCPServer(port,factory,interface=interface)
+ telnet_service.setName("EvenniaTelnet%s"%pstring)
+ PORTAL.services.addService(telnet_service)
+
+ INFO_DICT["telnet"].append("telnet%s: %s"%(ifacestr,port))
+
+
+ifSSL_ENABLED:
+
+ # Start Telnet+SSL game connection (requires PyOpenSSL).
+
+ fromevennia.server.portalimporttelnet_ssl
+
+ forinterfaceinSSL_INTERFACES:
+ ifacestr=""
+ ifinterfacenotin("0.0.0.0","::")orlen(SSL_INTERFACES)>1:
+ ifacestr="-%s"%interface
+ forportinSSL_PORTS:
+ pstring="%s:%s"%(ifacestr,port)
+ factory=protocol.ServerFactory()
+ factory.noisy=False
+ factory.sessionhandler=PORTAL_SESSIONS
+ factory.protocol=telnet_ssl.SSLProtocol
+
+ ssl_context=telnet_ssl.getSSLContext()
+ ifssl_context:
+ ssl_service=internet.SSLServer(
+ port,factory,telnet_ssl.getSSLContext(),interface=interface
+ )
+ ssl_service.setName("EvenniaSSL%s"%pstring)
+ PORTAL.services.addService(ssl_service)
+
+ INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s"%(ifacestr,port))
+ else:
+ INFO_DICT["telnet_ssl"].append(
+ "telnet+ssl%s: %s (deactivated - keys/cert unset)"%(ifacestr,port)
+ )
+
+
+ifSSH_ENABLED:
+
+ # Start SSH game connections. Will create a keypair in
+ # evennia/game if necessary.
+
+ fromevennia.server.portalimportssh
+
+ forinterfaceinSSH_INTERFACES:
+ ifacestr=""
+ ifinterfacenotin("0.0.0.0","::")orlen(SSH_INTERFACES)>1:
+ ifacestr="-%s"%interface
+ forportinSSH_PORTS:
+ pstring="%s:%s"%(ifacestr,port)
+ factory=ssh.makeFactory(
+ {
+ "protocolFactory":ssh.SshProtocol,
+ "protocolArgs":(),
+ "sessions":PORTAL_SESSIONS,
+ }
+ )
+ factory.noisy=False
+ ssh_service=internet.TCPServer(port,factory,interface=interface)
+ ssh_service.setName("EvenniaSSH%s"%pstring)
+ PORTAL.services.addService(ssh_service)
+
+ INFO_DICT["ssh"].append("ssh%s: %s"%(ifacestr,port))
+
+
+ifWEBSERVER_ENABLED:
+ fromevennia.server.webserverimportWebsite
+
+ # Start a reverse proxy to relay data to the Server-side webserver
+
+ websocket_started=False
+ forinterfaceinWEBSERVER_INTERFACES:
+ ifacestr=""
+ ifinterfacenotin("0.0.0.0","::")orlen(WEBSERVER_INTERFACES)>1:
+ ifacestr="-%s"%interface
+ forproxyport,serverportinWEBSERVER_PORTS:
+ web_root=EvenniaReverseProxyResource("127.0.0.1",serverport,"")
+ webclientstr=""
+ ifWEBCLIENT_ENABLED:
+ # create ajax client processes at /webclientdata
+ fromevennia.server.portalimportwebclient_ajax
+
+ ajax_webclient=webclient_ajax.AjaxWebClient()
+ ajax_webclient.sessionhandler=PORTAL_SESSIONS
+ web_root.putChild(b"webclientdata",ajax_webclient)
+ webclientstr="webclient (ajax only)"
+
+ ifWEBSOCKET_CLIENT_ENABLEDandnotwebsocket_started:
+ # start websocket client port for the webclient
+ # we only support one websocket client
+ fromevennia.server.portalimportwebclient
+ fromautobahn.twisted.websocketimportWebSocketServerFactory
+
+ w_interface=WEBSOCKET_CLIENT_INTERFACE
+ w_ifacestr=""
+ ifw_interfacenotin("0.0.0.0","::")orlen(WEBSERVER_INTERFACES)>1:
+ w_ifacestr="-%s"%w_interface
+ port=WEBSOCKET_CLIENT_PORT
+
+
[docs]classWebsocket(WebSocketServerFactory):
+ "Only here for better naming in logs"
+ pass
+
+ factory=Websocket()
+ factory.noisy=False
+ factory.protocol=webclient.WebSocketClient
+ factory.sessionhandler=PORTAL_SESSIONS
+ websocket_service=internet.TCPServer(port,factory,interface=w_interface)
+ websocket_service.setName("EvenniaWebSocket%s:%s"%(w_ifacestr,port))
+ PORTAL.services.addService(websocket_service)
+ websocket_started=True
+ webclientstr="webclient-websocket%s: %s"%(w_ifacestr,port)
+ INFO_DICT["webclient"].append(webclientstr)
+
+ ifWEB_PLUGINS_MODULE:
+ try:
+ web_root=WEB_PLUGINS_MODULE.at_webproxy_root_creation(web_root)
+ exceptExceptionase:# Legacy user has not added an at_webproxy_root_creation function in existing web plugins file
+ INFO_DICT["errors"]=(
+ "WARNING: WEB_PLUGINS_MODULE is enabled but at_webproxy_root_creation() not found - "
+ "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf."
+ )
+ web_root=Website(web_root,logPath=settings.HTTP_LOG_FILE)
+ web_root.is_portal=True
+ proxy_service=internet.TCPServer(proxyport,web_root,interface=interface)
+ proxy_service.setName("EvenniaWebProxy%s:%s"%(ifacestr,proxyport))
+ PORTAL.services.addService(proxy_service)
+ INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s"%(ifacestr,proxyport))
+ INFO_DICT["webserver_internal"].append("webserver: %s"%serverport)
+
+
+forplugin_moduleinPORTAL_SERVICES_PLUGIN_MODULES:
+ # external plugin services to start
+ ifplugin_module:
+ plugin_module.start_plugin_services(PORTAL)
+
+
[docs]classPortalSessionHandler(SessionHandler):
+ """
+ This object holds the sessions connected to the portal at any time.
+ It is synced with the server's equivalent SessionHandler over the AMP
+ connection.
+
+ Sessions register with the handler using the connect() method. This
+ will assign a new unique sessionid to the session and send that sessid
+ to the server using the AMP connection.
+
+ """
+
+
[docs]defat_server_connection(self):
+ """
+ Called when the Portal establishes connection with the Server.
+ At this point, the AMP connection is already established.
+
+ """
+ self.connection_time=time.time()
+
+
[docs]defconnect(self,session):
+ """
+ Called by protocol at first connect. This adds a not-yet
+ authenticated session using an ever-increasing counter for
+ sessid.
+
+ Args:
+ session (PortalSession): The Session connecting.
+
+ Notes:
+ We implement a throttling mechanism here to limit the speed at
+ which new connections are accepted - this is both a stop
+ against DoS attacks as well as helps using the Dummyrunner
+ tester with a large number of connector dummies.
+
+ """
+ global_CONNECTION_QUEUE
+
+ ifsession:
+ # assign if we are first-connectors
+ ifnotsession.sessid:
+ # if the session already has a sessid (e.g. being inherited in the
+ # case of a webclient auto-reconnect), keep it
+ self.latest_sessid+=1
+ session.sessid=self.latest_sessid
+ session.server_connected=False
+ _CONNECTION_QUEUE.appendleft(session)
+ iflen(_CONNECTION_QUEUE)>1:
+ session.data_out(
+ text=[
+ [
+ "%s DoS protection is active. You are queued to connect in %g seconds ..."
+ %(
+ settings.SERVERNAME,
+ len(_CONNECTION_QUEUE)*_MIN_TIME_BETWEEN_CONNECTS,
+ )
+ ],
+ {},
+ ]
+ )
+ now=time.time()
+ if(
+ now-self.connection_last<_MIN_TIME_BETWEEN_CONNECTS
+ )ornotself.portal.amp_protocol:
+ ifnotsessionornotself.connection_task:
+ self.connection_task=reactor.callLater(
+ _MIN_TIME_BETWEEN_CONNECTS,self.connect,None
+ )
+ self.connection_last=now
+ return
+ elifnotsession:
+ if_CONNECTION_QUEUE:
+ # keep launching tasks until queue is empty
+ self.connection_task=reactor.callLater(
+ _MIN_TIME_BETWEEN_CONNECTS,self.connect,None
+ )
+ else:
+ self.connection_task=None
+ self.connection_last=now
+
+ if_CONNECTION_QUEUE:
+ # sync with server-side
+ session=_CONNECTION_QUEUE.pop()
+ sessdata=session.get_sync_data()
+
+ self[session.sessid]=session
+ session.server_connected=True
+ self.portal.amp_protocol.send_AdminPortal2Server(
+ session,operation=PCONN,sessiondata=sessdata
+ )
+
+
[docs]defsync(self,session):
+ """
+ Called by the protocol of an already connected session. This
+ can be used to sync the session info in a delayed manner, such
+ as when negotiation and handshakes are delayed.
+
+ Args:
+ session (PortalSession): Session to sync.
+
+ """
+ ifsession.sessidandsession.server_connected:
+ # only use if session already has sessid and has already connected
+ # once to the server - if so we must re-sync woth the server, otherwise
+ # we skip this step.
+ sessdata=session.get_sync_data()
+ ifself.portal.amp_protocol:
+ # we only send sessdata that should not have changed
+ # at the server level at this point
+ sessdata=dict(
+ (key,val)
+ forkey,valinsessdata.items()
+ ifkey
+ in(
+ "protocol_key",
+ "address",
+ "sessid",
+ "csessid",
+ "conn_time",
+ "protocol_flags",
+ "server_data",
+ )
+ )
+ self.portal.amp_protocol.send_AdminPortal2Server(
+ session,operation=PCONNSYNC,sessiondata=sessdata
+ )
+
+
[docs]defdisconnect(self,session):
+ """
+ Called from portal when the connection is closed from the
+ portal side.
+
+ Args:
+ session (PortalSession): Session to disconnect.
+ delete (bool, optional): Delete the session from
+ the handler. Only time to not do this is when
+ this is called from a loop, such as from
+ self.disconnect_all().
+
+ """
+ global_CONNECTION_QUEUE
+ ifsessionin_CONNECTION_QUEUE:
+ # connection was already dropped before we had time
+ # to forward this to the Server, so now we just remove it.
+ _CONNECTION_QUEUE.remove(session)
+ return
+
+ ifsession.sessidinselfandnothasattr(self,"_disconnect_all"):
+ # if this was called directly from the protocol, the
+ # connection is already dead and we just need to cleanup
+ delself[session.sessid]
+
+ # Tell the Server to disconnect its version of the Session as well.
+ self.portal.amp_protocol.send_AdminPortal2Server(session,operation=PDISCONN)
+
+
[docs]defdisconnect_all(self):
+ """
+ Disconnect all sessions, informing the Server.
+ """
+
+ def_callback(result,sessionhandler):
+ # we set a watchdog to stop self.disconnect from deleting
+ # sessions while we are looping over them.
+ sessionhandler._disconnect_all=True
+ forsessioninsessionhandler.values():
+ session.disconnect()
+ delsessionhandler._disconnect_all
+
+ # inform Server; wait until finished sending before we continue
+ # removing all the sessions.
+ self.portal.amp_protocol.send_AdminPortal2Server(
+ DUMMYSESSION,operation=PDISCONNALL
+ ).addCallback(_callback,self)
+
+
[docs]defserver_connect(self,protocol_path="",config=dict()):
+ """
+ Called by server to force the initialization of a new protocol
+ instance. Server wants this instance to get a unique sessid and to be
+ connected back as normal. This is used to initiate irc/rss etc
+ connections.
+
+ Args:
+ protocol_path (str): Full python path to the class factory
+ for the protocol used, eg
+ 'evennia.server.portal.irc.IRCClientFactory'
+ config (dict): Dictionary of configuration options, fed as
+ `**kwarg` to protocol class `__init__` method.
+
+ Raises:
+ RuntimeError: If The correct factory class is not found.
+
+ Notes:
+ The called protocol class must have a method start()
+ that calls the portalsession.connect() as a normal protocol.
+
+ """
+ global_MOD_IMPORT
+ ifnot_MOD_IMPORT:
+ fromevennia.utils.utilsimportvariable_from_moduleas_MOD_IMPORT
+ path,clsname=protocol_path.rsplit(".",1)
+ cls=_MOD_IMPORT(path,clsname)
+ ifnotcls:
+ raiseRuntimeError("ServerConnect: protocol factory '%s' not found."%protocol_path)
+ protocol=cls(self,**config)
+ protocol.start()
+
+
[docs]defserver_disconnect(self,session,reason=""):
+ """
+ Called by server to force a disconnect by sessid.
+
+ Args:
+ session (portalsession): Session to disconnect.
+ reason (str, optional): Motivation for disconnect.
+
+ """
+ ifsession:
+ session.disconnect(reason)
+ ifsession.sessidinself:
+ # in case sess.disconnect doesn't delete it
+ delself[session.sessid]
+ delsession
+
+
[docs]defserver_disconnect_all(self,reason=""):
+ """
+ Called by server when forcing a clean disconnect for everyone.
+
+ Args:
+ reason (str, optional): Motivation for disconnect.
+
+ """
+ forsessioninlist(self.values()):
+ session.disconnect(reason)
+ delsession
+ self.clear()
+
+
[docs]defserver_logged_in(self,session,data):
+ """
+ The server tells us that the session has been authenticated.
+ Update it. Called by the Server.
+
+ Args:
+ session (Session): Session logging in.
+ data (dict): The session sync data.
+
+ """
+ session.load_sync_data(data)
+ session.at_login()
+
+
[docs]defserver_session_sync(self,serversessions,clean=True):
+ """
+ Server wants to save data to the portal, maybe because it's
+ about to shut down. We don't overwrite any sessions here, just
+ update them in-place.
+
+ Args:
+ serversessions (dict): This is a dictionary
+
+ `{sessid:{property:value},...}` describing
+ the properties to sync on all sessions.
+ clean (bool): If True, remove any Portal sessions that are
+ not included in serversessions.
+ """
+ to_save=[sessidforsessidinserversessionsifsessidinself]
+ # save protocols
+ forsessidinto_save:
+ self[sessid].load_sync_data(serversessions[sessid])
+ ifclean:
+ # disconnect out-of-sync missing protocols
+ to_delete=[sessidforsessidinselfifsessidnotinto_save]
+ forsessidinto_delete:
+ self.server_disconnect(sessid)
+
+
[docs]defcount_loggedin(self,include_unloggedin=False):
+ """
+ Count loggedin connections, alternatively count all connections.
+
+ Args:
+ include_unloggedin (bool): Also count sessions that have
+ not yet authenticated.
+
+ Returns:
+ count (int): Number of sessions.
+
+ """
+ returnlen(self.get_sessions(include_unloggedin=include_unloggedin))
+
+
[docs]defsessions_from_csessid(self,csessid):
+ """
+ Given a session id, retrieve the session (this is primarily
+ intended to be called by web clients)
+
+ Args:
+ csessid (int): Session id.
+
+ Returns:
+ session (list): The matching session, if found.
+
+ """
+ return[
+ sess
+ forsessinself.get_sessions(include_unloggedin=True)
+ ifhasattr(sess,"csessid")andsess.csessidandsess.csessid==csessid
+ ]
+
+
[docs]defannounce_all(self,message):
+ """
+ Send message to all connected sessions.
+
+ Args:
+ message (str): Message to relay.
+
+ Notes:
+ This will create an on-the fly text-type
+ send command.
+
+ """
+ forsessioninself.values():
+ self.data_out(session,text=[[message],{}])
+
+
[docs]defdata_in(self,session,**kwargs):
+ """
+ Called by portal sessions for relaying data coming
+ in from the protocol to the server.
+
+ Args:
+ session (PortalSession): Session receiving data.
+
+ Keyword Args:
+ kwargs (any): Other data from protocol.
+
+ Notes:
+ Data is serialized before passed on.
+
+ """
+ try:
+ text=kwargs["text"]
+ if(_MAX_CHAR_LIMIT>0)andlen(text)>_MAX_CHAR_LIMIT:
+ ifsession:
+ self.data_out(session,text=[[_ERROR_MAX_CHAR],{}])
+ return
+ exceptException:
+ # if there is a problem to send, we continue
+ pass
+ ifsession:
+ now=time.time()
+
+ try:
+ command_counter_reset=session.command_counter_reset
+ exceptAttributeError:
+ command_counter_reset=session.command_counter_reset=now
+ session.command_counter=0
+
+ # global command-rate limit
+ ifmax(0,now-command_counter_reset)>1.0:
+ # more than a second since resetting the counter. Refresh.
+ session.command_counter_reset=now
+ session.command_counter=0
+
+ session.command_counter+=1
+
+ ifsession.command_counter*_MIN_TIME_BETWEEN_COMMANDS>1.0:
+ self.data_out(session,text=[[_ERROR_COMMAND_OVERFLOW],{}])
+ return
+
+ ifnotself.portal.amp_protocol:
+ # this can happen if someone connects before AMP connection
+ # was established (usually on first start)
+ reactor.callLater(1.0,self.data_in,session,**kwargs)
+ return
+
+ # scrub data
+ kwargs=self.clean_senddata(session,kwargs)
+
+ # relay data to Server
+ session.cmd_last=now
+ self.portal.amp_protocol.send_MsgPortal2Server(session,**kwargs)
+
+
[docs]defdata_out(self,session,**kwargs):
+ """
+ Called by server for having the portal relay messages and data
+ to the correct session protocol.
+
+ Args:
+ session (Session): Session sending data.
+
+ Keyword Args:
+ kwargs (any): Each key is a command instruction to the
+ protocol on the form key = [[args],{kwargs}]. This will
+ call a method send_<key> on the protocol. If no such
+ method exixts, it sends the data to a method send_default.
+
+ """
+ # from evennia.server.profiling.timetrace import timetrace # DEBUG
+ # text = timetrace(text, "portalsessionhandler.data_out") # DEBUG
+
+ # distribute outgoing data to the correct session methods.
+ ifsession:
+ forcmdname,(cmdargs,cmdkwargs)inkwargs.items():
+ funcname="send_%s"%cmdname.strip().lower()
+ ifhasattr(session,funcname):
+ # better to use hassattr here over try..except
+ # - avoids hiding AttributeErrors in the call.
+ try:
+ getattr(session,funcname)(*cmdargs,**cmdkwargs)
+ exceptException:
+ log_trace()
+ else:
+ try:
+ # note that send_default always takes cmdname
+ # as arg too.
+ session.send_default(cmdname,*cmdargs,**cmdkwargs)
+ exceptException:
+ log_trace()
+"""
+RSS parser for Evennia
+
+This connects an RSS feed to an in-game Evennia channel, sending messages
+to the channel whenever the feed updates.
+
+"""
+fromtwisted.internetimporttask,threads
+fromdjango.confimportsettings
+fromevennia.server.sessionimportSession
+fromevennia.utilsimportlogger
+
+RSS_ENABLED=settings.RSS_ENABLED
+# RETAG = re.compile(r'<[^>]*?>')
+
+ifRSS_ENABLED:
+ try:
+ importfeedparser
+ exceptImportError:
+ raiseImportError(
+ "RSS requires python-feedparser to be installed. Install or set RSS_ENABLED=False."
+ )
+
+
+
[docs]classRSSReader(Session):
+ """
+ A simple RSS reader using the feedparser module.
+
+ """
+
+
[docs]def__init__(self,factory,url,rate):
+ """
+ Initialize the reader.
+
+ Args:
+ factory (RSSFactory): The protocol factory.
+ url (str): The RSS url.
+ rate (int): The seconds between RSS lookups.
+
+ """
+ self.url=url
+ self.rate=rate
+ self.factory=factory
+ self.old_entries={}
+
+
[docs]defget_new(self):
+ """
+ Returns list of new items.
+
+ """
+ feed=feedparser.parse(self.url)
+ new_entries=[]
+ forentryinfeed["entries"]:
+ idval=entry["id"]+entry.get("updated","")
+ ifidvalnotinself.old_entries:
+ self.old_entries[idval]=entry
+ new_entries.append(entry)
+ returnnew_entries
+
+
[docs]defdisconnect(self,reason=None):
+ """
+ Disconnect from feed.
+
+ Args:
+ reason (str, optional): Motivation for the disconnect.
+
+ """
+ ifself.factory.taskandself.factory.task.running:
+ self.factory.task.stop()
+ self.sessionhandler.disconnect(self)
+
+ def_callback(self,new_entries,init):
+ """
+ Called when RSS returns.
+
+ Args:
+ new_entries (list): List of new RSS entries since last.
+ init (bool): If this is a startup operation (at which
+ point all entries are considered new).
+
+ """
+ ifnotinit:
+ # for initialization we just ignore old entries
+ forentryinreversed(new_entries):
+ self.data_in(entry)
+
+
[docs]defdata_in(self,text=None,**kwargs):
+ """
+ Data RSS -> Evennia.
+
+ Keyword Args:
+ text (str): Incoming text
+ kwargs (any): Options from protocol.
+
+ """
+ self.sessionhandler.data_in(self,bot_data_in=text,**kwargs)
[docs]defupdate(self,init=False):
+ """
+ Request the latest version of feed.
+
+ Args:
+ init (bool, optional): If this is an initialization call
+ or not (during init, all entries are conidered new).
+
+ Notes:
+ This call is done in a separate thread to avoid blocking
+ on slow connections.
+
+ """
+ return(
+ threads.deferToThread(self.get_new)
+ .addCallback(self._callback,init)
+ .addErrback(self._errback)
+ )
[docs]def__init__(self,sessionhandler,uid=None,url=None,rate=None):
+ """
+ Initialize the bot.
+
+ Args:
+ sessionhandler (PortalSessionHandler): The main sessionhandler object.
+ uid (int): User id for the bot.
+ url (str): The RSS URL.
+ rate (int): How often for the RSS to request the latest RSS entries.
+
+ """
+ self.sessionhandler=sessionhandler
+ self.url=url
+ self.rate=rate
+ self.uid=uid
+ self.bot=RSSReader(self,url,rate)
+ self.task=None
+
+
[docs]defstart(self):
+ """
+ Called by portalsessionhandler. Starts the bot.
+ """
+
+ deferrback(fail):
+ logger.log_err(fail.value)
+
+ # set up session and connect it to sessionhandler
+ self.bot.init_session("rssbot",self.url,self.sessionhandler)
+ self.bot.uid=self.uid
+ self.bot.logged_in=True
+ self.sessionhandler.connect(self.bot)
+
+ # start repeater task
+ self.bot.update(init=True)
+ self.task=task.LoopingCall(self.bot.update)
+ ifself.rate:
+ self.task.start(self.rate,now=False).addErrback(errback)
+"""
+This module implements the ssh (Secure SHell) protocol for encrypted
+connections.
+
+This depends on a generic session module that implements the actual
+login procedure of the game, tracks sessions etc.
+
+Using standard ssh client,
+
+"""
+
+importos
+importre
+
+fromtwisted.cred.checkersimportcredentials
+fromtwisted.cred.portalimportPortal
+fromtwisted.conch.interfacesimportIConchUser
+
+_SSH_IMPORT_ERROR="""
+ERROR: Missing crypto library for SSH. Install it with
+
+ pip install cryptography pyasn1 bcrypt
+
+(On older Twisted versions you may have to do 'pip install pycrypto pyasn1' instead).
+
+If you get a compilation error you must install a C compiler and the
+SSL dev headers (On Debian-derived systems this is the gcc and libssl-dev
+packages).
+"""
+
+try:
+ fromtwisted.conch.ssh.keysimportKey
+exceptImportError:
+ raiseImportError(_SSH_IMPORT_ERROR)
+
+fromtwisted.conch.ssh.userauthimportSSHUserAuthServer
+fromtwisted.conch.sshimportcommon
+fromtwisted.conch.insultsimportinsults
+fromtwisted.conch.manhole_sshimportTerminalRealm,_Glue,ConchFactory
+fromtwisted.conch.manholeimportManhole,recvline
+fromtwisted.internetimportdefer,protocol
+fromtwisted.conchimportinterfacesasiconch
+fromtwisted.pythonimportcomponents
+fromdjango.confimportsettings
+
+fromevennia.serverimportsession
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.utilsimportansi
+fromevennia.utils.utilsimportto_str
+
+_RE_N=re.compile(r"\|n$")
+_RE_SCREENREADER_REGEX=re.compile(
+ r"%s"%settings.SCREENREADER_REGEX_STRIP,re.DOTALL+re.MULTILINE
+)
+_GAME_DIR=settings.GAME_DIR
+_PRIVATE_KEY_FILE=os.path.join(_GAME_DIR,"server","ssh-private.key")
+_PUBLIC_KEY_FILE=os.path.join(_GAME_DIR,"server","ssh-public.key")
+_KEY_LENGTH=2048
+
+CTRL_C="\x03"
+CTRL_D="\x04"
+CTRL_BACKSLASH="\x1c"
+CTRL_L="\x0c"
+
+_NO_AUTOGEN="""
+Evennia could not generate SSH private- and public keys ({{err}})
+Using conch default keys instead.
+
+If this error persists, create the keys manually (using the tools for your OS)
+and put them here:
+{}
+{}
+""".format(
+ _PRIVATE_KEY_FILE,_PUBLIC_KEY_FILE
+)
+
+
+# not used atm
+
[docs]classSSHServerFactory(protocol.ServerFactory):
+ "This is only to name this better in logs"
+ noisy=False
+
+
[docs]classSshProtocol(Manhole,session.Session):
+ """
+ Each account connecting over ssh gets this protocol assigned to
+ them. All communication between game and account goes through
+ here.
+
+ """
+
+ noisy=False
+
+
[docs]def__init__(self,starttuple):
+ """
+ For setting up the account. If account is not None then we'll
+ login automatically.
+
+ Args:
+ starttuple (tuple): A (account, factory) tuple.
+
+ """
+ self.protocol_key="ssh"
+ self.authenticated_account=starttuple[0]
+ # obs must not be called self.factory, that gets overwritten!
+ self.cfactory=starttuple[1]
+
+
[docs]defterminalSize(self,width,height):
+ """
+ Initialize the terminal and connect to the new session.
+
+ Args:
+ width (int): Width of terminal.
+ height (int): Height of terminal.
+
+ """
+ # Clear the previous input line, redraw it at the new
+ # cursor position
+ self.terminal.eraseDisplay()
+ self.terminal.cursorHome()
+ self.width=width
+ self.height=height
+
+ # initialize the session
+ client_address=self.getClientAddress()
+ client_address=client_address.hostifclient_addresselseNone
+ self.init_session("ssh",client_address,self.cfactory.sessionhandler)
+
+ # since we might have authenticated already, we might set this here.
+ ifself.authenticated_account:
+ self.logged_in=True
+ self.uid=self.authenticated_account.id
+ self.sessionhandler.connect(self)
+
+
[docs]defconnectionMade(self):
+ """
+ This is called when the connection is first established.
+
+ """
+ recvline.HistoricRecvLine.connectionMade(self)
+ self.keyHandlers[CTRL_C]=self.handle_INT
+ self.keyHandlers[CTRL_D]=self.handle_EOF
+ self.keyHandlers[CTRL_L]=self.handle_FF
+ self.keyHandlers[CTRL_BACKSLASH]=self.handle_QUIT
+
+ # initalize
+
+
[docs]defhandle_INT(self):
+ """
+ Handle ^C as an interrupt keystroke by resetting the current
+ input variables to their initial state.
+
+ """
+ self.lineBuffer=[]
+ self.lineBufferIndex=0
+
+ self.terminal.nextLine()
+ self.terminal.write("KeyboardInterrupt")
+ self.terminal.nextLine()
+
+
[docs]defhandle_EOF(self):
+ """
+ Handles EOF generally used to exit.
+
+ """
+ ifself.lineBuffer:
+ self.terminal.write("\a")
+ else:
+ self.handle_QUIT()
+
+
[docs]defhandle_FF(self):
+ """
+ Handle a 'form feed' byte - generally used to request a screen
+ refresh/redraw.
+
+ """
+ self.terminal.eraseDisplay()
+ self.terminal.cursorHome()
+
+
[docs]defhandle_QUIT(self):
+ """
+ Quit, end, and lose the connection.
+
+ """
+ self.terminal.loseConnection()
+
+
[docs]defconnectionLost(self,reason=None):
+ """
+ This is executed when the connection is lost for whatever
+ reason. It can also be called directly, from the disconnect
+ method.
+
+ Args:
+ reason (str): Motivation for loosing connection.
+
+ """
+ insults.TerminalProtocol.connectionLost(self,reason)
+ self.sessionhandler.disconnect(self)
+ self.terminal.loseConnection()
+
+
[docs]defgetClientAddress(self):
+ """
+ Get client address.
+
+ Returns:
+ address_and_port (tuple): The client's address and port in
+ a tuple. For example `('127.0.0.1', 41917)`.
+
+ """
+ returnself.terminal.transport.getPeer()
+
+
[docs]deflineReceived(self,string):
+ """
+ Communication User -> Evennia. Any line return indicates a
+ command for the purpose of the MUD. So we take the user input
+ and pass it on to the game engine.
+
+ Args:
+ string (str): Input text.
+
+ """
+ self.sessionhandler.data_in(self,text=string)
+
+
[docs]defsendLine(self,string):
+ """
+ Communication Evennia -> User. Any string sent should
+ already have been properly formatted and processed before
+ reaching this point.
+
+ Args:
+ string (str): Output text.
+
+ """
+ forlineinstring.split("\n"):
+ # the telnet-specific method for sending
+ self.terminal.write(line)
+ self.terminal.nextLine()
+
+ # session-general method hooks
+
+
[docs]defat_login(self):
+ """
+ Called when this session gets authenticated by the server.
+ """
+ pass
+
+
[docs]defdisconnect(self,reason="Connection closed. Goodbye for now."):
+ """
+ Disconnect from server.
+
+ Args:
+ reason (str): Motivation for disconnect.
+
+ """
+ ifreason:
+ self.data_out(text=((reason,),{}))
+ self.connectionLost(reason)
+
+
[docs]defdata_out(self,**kwargs):
+ """
+ Data Evennia -> User
+
+ Keyword Args:
+ kwargs (any): Options to the protocol.
+
+ """
+ self.sessionhandler.data_out(self,**kwargs)
+
+
[docs]defsend_text(self,*args,**kwargs):
+ """
+ Send text data. This is an in-band telnet operation.
+
+ Args:
+ text (str): The first argument is always the text string to send. No other arguments
+ are considered.
+ Keyword Args:
+ options (dict): Send-option flags:
+
+ - mxp: Enforce MXP link support.
+ - ansi: Enforce no ANSI colors.
+ - xterm256: Enforce xterm256 colors, regardless of TTYPE setting.
+ - nocolor: Strip all colors.
+ - raw: Pass string through without any ansi processing
+ (i.e. include Evennia ansi markers but do not
+ convert them into ansi tokens)
+ - echo: Turn on/off line echo on the client. Turn
+ off line echo for client, for example for password.
+ Note that it must be actively turned back on again!
+
+ """
+ # print "telnet.send_text", args,kwargs # DEBUG
+ text=args[0]ifargselse""
+ iftextisNone:
+ return
+ text=to_str(text)
+
+ # handle arguments
+ options=kwargs.get("options",{})
+ flags=self.protocol_flags
+ xterm256=options.get("xterm256",flags.get("XTERM256",True))
+ useansi=options.get("ansi",flags.get("ANSI",True))
+ raw=options.get("raw",flags.get("RAW",False))
+ nocolor=options.get("nocolor",flags.get("NOCOLOR")ornot(xterm256oruseansi))
+ # echo = options.get("echo", None) # DEBUG
+ screenreader=options.get("screenreader",flags.get("SCREENREADER",False))
+
+ ifscreenreader:
+ # screenreader mode cleans up output
+ text=ansi.parse_ansi(text,strip_ansi=True,xterm256=False,mxp=False)
+ text=_RE_SCREENREADER_REGEX.sub("",text)
+
+ ifraw:
+ # no processing
+ self.sendLine(text)
+ return
+ else:
+ # we need to make sure to kill the color at the end in order
+ # to match the webclient output.
+ linetosend=ansi.parse_ansi(
+ _RE_N.sub("",text)+("||n"iftext.endswith("|")else"|n"),
+ strip_ansi=nocolor,
+ xterm256=xterm256,
+ mxp=False,
+ )
+ self.sendLine(linetosend)
[docs]defauth_password(self,packet):
+ """
+ Password authentication.
+
+ Used mostly for setting up the transport so we can query
+ username and password later.
+
+ Args:
+ packet (Packet): Auth packet.
+
+ """
+ password=common.getNS(packet[1:])[0]
+ c=credentials.UsernamePassword(self.user,password)
+ c.transport=self.transport
+ returnself.portal.login(c,None,IConchUser).addErrback(self._ebPassword)
+
+
+
[docs]classAccountDBPasswordChecker(object):
+ """
+ Checks the django db for the correct credentials for
+ username/password otherwise it returns the account or None which is
+ useful for the Realm.
+
+ """
+
+ noisy=False
+ credentialInterfaces=(credentials.IUsernamePassword,)
+
+
[docs]classPassAvatarIdTerminalRealm(TerminalRealm):
+ """
+ Returns an avatar that passes the avatarId through to the
+ protocol. This is probably not the best way to do it.
+
+ """
+
+ noisy=False
+
+ def_getAvatar(self,avatarId):
+ comp=components.Componentized()
+ user=self.userFactory(comp,avatarId)
+ sess=self.sessionFactory(comp)
+
+ sess.transportFactory=self.transportFactory
+ sess.chainedProtocolFactory=lambda:self.chainedProtocolFactory(avatarId)
+
+ comp.setComponent(iconch.IConchUser,user)
+ comp.setComponent(iconch.ISession,sess)
+
+ returnuser
+
+
+
[docs]classTerminalSessionTransport_getPeer(object):
+ """
+ Taken from twisted's TerminalSessionTransport which doesn't
+ provide getPeer to the transport. This one does.
+
+ """
+
+ noisy=False
+
+
[docs]defgetKeyPair(pubkeyfile,privkeyfile):
+ """
+ This function looks for RSA keypair files in the current directory. If they
+ do not exist, the keypair is created.
+ """
+
+ ifnot(os.path.exists(pubkeyfile)andos.path.exists(privkeyfile)):
+ # No keypair exists. Generate a new RSA keypair
+ fromcryptography.hazmat.backendsimportdefault_backend
+ fromcryptography.hazmat.primitives.asymmetricimportrsa
+
+ rsa_key=Key(
+ rsa.generate_private_key(
+ public_exponent=65537,key_size=_KEY_LENGTH,backend=default_backend()
+ )
+ )
+ public_key_string=rsa_key.public().toString(type="OPENSSH").decode()
+ private_key_string=rsa_key.toString(type="OPENSSH").decode()
+
+ # save keys for the future.
+ withopen(privkeyfile,"wt")aspfile:
+ pfile.write(private_key_string)
+ print("Created SSH private key in '{}'".format(_PRIVATE_KEY_FILE))
+ withopen(pubkeyfile,"wt")aspfile:
+ pfile.write(public_key_string)
+ print("Created SSH public key in '{}'".format(_PUBLIC_KEY_FILE))
+ else:
+ withopen(pubkeyfile)aspfile:
+ public_key_string=pfile.read()
+ withopen(privkeyfile)aspfile:
+ private_key_string=pfile.read()
+
+ returnKey.fromString(public_key_string),Key.fromString(private_key_string)
[docs]defverify_SSL_key_and_cert(keyfile,certfile):
+ """
+ This function looks for RSA key and certificate in the current
+ directory. If files ssl.key and ssl.cert does not exist, they
+ are created.
+ """
+
+ ifnot(os.path.exists(keyfile)andos.path.exists(certfile)):
+ # key/cert does not exist. Create.
+ importsubprocess
+ fromCrypto.PublicKeyimportRSA
+ fromtwisted.conch.ssh.keysimportKey
+
+ print(" Creating SSL key and certificate ... ",end=" ")
+
+ try:
+ # create the RSA key and store it.
+ KEY_LENGTH=1024
+ rsaKey=Key(RSA.generate(KEY_LENGTH))
+ keyString=rsaKey.toString(type="OPENSSH")
+ file(keyfile,"w+b").write(keyString)
+ exceptExceptionaserr:
+ print(NO_AUTOGEN.format(err=err,keyfile=keyfile))
+ sys.exit(5)
+
+ # try to create the certificate
+ CERT_EXPIRE=365*20# twenty years validity
+ # default:
+ # openssl req -new -x509 -key ssl.key -out ssl.cert -days 7300
+ exestring="openssl req -new -x509 -key %s -out %s -days %s"%(
+ keyfile,
+ certfile,
+ CERT_EXPIRE,
+ )
+ try:
+ subprocess.call(exestring)
+ exceptOSErroraserr:
+ raiseOSError(
+ NO_AUTOCERT.format(err=err,certfile=certfile,keyfile=keyfile,exestring=exestring)
+ )
+ print("done.")
+
+
+
[docs]defgetSSLContext():
+ """
+ This is called by the portal when creating the SSL context
+ server-side.
+
+ Returns:
+ ssl_context (tuple): A key and certificate that is either
+ existing previously or or created on the fly.
+
+ """
+ keyfile=os.path.join(_GAME_DIR,"server","ssl.key")
+ certfile=os.path.join(_GAME_DIR,"server","ssl.cert")
+
+ verify_SSL_key_and_cert(keyfile,certfile)
+ returntwisted_ssl.DefaultOpenSSLContextFactory(keyfile,certfile)
+"""
+
+SUPPRESS-GO-AHEAD
+
+This supports suppressing or activating Evennia
+the GO-AHEAD telnet operation after every server reply.
+If the client sends no explicit DONT SUPRESS GO-AHEAD,
+Evennia will default to supressing it since many clients
+will fail to use it and has no knowledge of this standard.
+
+It is set as the NOGOAHEAD protocol_flag option.
+
+http://www.faqs.org/rfcs/rfc858.html
+
+"""
+SUPPRESS_GA=bytes([3])# b"\x03"
+
+# default taken from telnet specification
+
+# try to get the customized mssp info, if it exists.
+
+
+
[docs]classSuppressGA(object):
+ """
+ Implements the SUPRESS-GO-AHEAD protocol. Add this to a variable on the telnet
+ protocol to set it up.
+
+ """
+
+
[docs]def__init__(self,protocol):
+ """
+ Initialize suppression of GO-AHEADs.
+
+ Args:
+ protocol (Protocol): The active protocol instance.
+
+ """
+ self.protocol=protocol
+
+ self.protocol.protocol_flags["NOGOAHEAD"]=True
+ self.protocol.protocol_flags[
+ "NOPROMPTGOAHEAD"
+ ]=True# Used to send a GA after a prompt line only, set in TTYPE (per client)
+ # tell the client that we prefer to suppress GA ...
+ self.protocol.will(SUPPRESS_GA).addCallbacks(self.will_suppress_ga,self.wont_suppress_ga)
+
+
[docs]defwont_suppress_ga(self,option):
+ """
+ Called when client requests to not suppress GA.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.protocol.protocol_flags["NOGOAHEAD"]=False
+ self.protocol.handshake_done()
+
+
[docs]defwill_suppress_ga(self,option):
+ """
+ Client will suppress GA
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.protocol.protocol_flags["NOGOAHEAD"]=True
+ self.protocol.handshake_done()
[docs]classTelnetProtocol(Telnet,StatefulTelnetProtocol,Session):
+ """
+ Each player connecting over telnet (ie using most traditional mud
+ clients) gets a telnet protocol instance assigned to them. All
+ communication between game and player goes through here.
+ """
+
+
[docs]defdataReceived(self,data):
+ """
+ Unused by default, but a good place to put debug printouts
+ of incoming data.
+ """
+ # print(f"telnet dataReceived: {data}")
+ try:
+ super().dataReceived(data)
+ exceptValueErroraserr:
+ fromevennia.utilsimportlogger
+ logger.log_err(f"Malformed telnet input: {err}")
+
+
[docs]defconnectionMade(self):
+ """
+ This is called when the connection is first established.
+
+ """
+ # important in order to work normally with standard telnet
+ self.do(LINEMODE).addErrback(self._wont_linemode)
+ # initialize the session
+ self.line_buffer=b""
+ client_address=self.transport.client
+ client_address=client_address[0]ifclient_addresselseNone
+ # this number is counted down for every handshake that completes.
+ # when it reaches 0 the portal/server syncs their data
+ self.handshakes=8# suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp
+
+ self.init_session(self.protocol_key,client_address,self.factory.sessionhandler)
+ self.protocol_flags["ENCODING"]=settings.ENCODINGS[0]ifsettings.ENCODINGSelse"utf-8"
+ # add this new connection to sessionhandler so
+ # the Server becomes aware of it.
+ self.sessionhandler.connect(self)
+ # change encoding to ENCODINGS[0] which reflects Telnet default encoding
+
+ # suppress go-ahead
+ self.sga=suppress_ga.SuppressGA(self)
+ # negotiate client size
+ self.naws=naws.Naws(self)
+ # negotiate ttype (client info)
+ # Obs: mudlet ttype does not seem to work if we start mccp before ttype. /Griatch
+ self.ttype=ttype.Ttype(self)
+ # negotiate mccp (data compression) - turn this off for wireshark analysis
+ self.mccp=Mccp(self)
+ # negotiate mssp (crawler communication)
+ self.mssp=mssp.Mssp(self)
+ # oob communication (MSDP, GMCP) - two handshake calls!
+ self.oob=telnet_oob.TelnetOOB(self)
+ # mxp support
+ self.mxp=Mxp(self)
+
+ fromevennia.utils.utilsimportdelay
+
+ # timeout the handshakes in case the client doesn't reply at all
+ self._handshake_delay=delay(2,callback=self.handshake_done,timeout=True)
+
+ # TCP/IP keepalive watches for dead links
+ self.transport.setTcpKeepAlive(1)
+ # The TCP/IP keepalive is not enough for some networks;
+ # we have to complement it with a NOP keep-alive.
+ self.protocol_flags["NOPKEEPALIVE"]=True
+ self.nop_keep_alive=None
+ self.toggle_nop_keepalive()
+
+ def_wont_linemode(self,*args):
+ """
+ Client refuses do(linemode). This is common for MUD-specific
+ clients, but we must ask for the sake of raw telnet. We ignore
+ this error.
+ """
+ pass
+
+ def_send_nop_keepalive(self):
+ """Send NOP keepalive unless flag is set"""
+ ifself.protocol_flags.get("NOPKEEPALIVE"):
+ self._write(IAC+NOP)
+
+
[docs]deftoggle_nop_keepalive(self):
+ """
+ Allow to toggle the NOP keepalive for those sad clients that
+ can't even handle a NOP instruction. This is turned off by the
+ protocol_flag NOPKEEPALIVE (settable e.g. by the default
+ `@option` command).
+ """
+ ifself.nop_keep_aliveandself.nop_keep_alive.running:
+ self.nop_keep_alive.stop()
+ else:
+ self.nop_keep_alive=LoopingCall(self._send_nop_keepalive)
+ self.nop_keep_alive.start(30,now=False)
+
+
[docs]defhandshake_done(self,timeout=False):
+ """
+ This is called by all telnet extensions once they are finished.
+ When all have reported, a sync with the server is performed.
+ The system will force-call this sync after a small time to handle
+ clients that don't reply to handshakes at all.
+ """
+ iftimeout:
+ ifself.handshakes>0:
+ self.handshakes=0
+ self.sessionhandler.sync(self)
+ else:
+ self.handshakes-=1
+ ifself.handshakes<=0:
+ # do the sync
+ self.sessionhandler.sync(self)
+
+
[docs]defat_login(self):
+ """
+ Called when this session gets authenticated by the server.
+ """
+ pass
+
+
[docs]defenableRemote(self,option):
+ """
+ This sets up the remote-activated options we allow for this protocol.
+
+ Args:
+ option (char): The telnet option to enable.
+
+ Returns:
+ enable (bool): If this option should be enabled.
+
+ """
+ ifoption==LINEMODE:
+ # make sure to activate line mode with local editing for all clients
+ self.requestNegotiation(
+ LINEMODE,MODE+bytes(chr(ord(LINEMODE_EDIT)+ord(LINEMODE_TRAPSIG)),"ascii")
+ )
+ returnTrue
+ else:
+ return(
+ option==ttype.TTYPE
+ oroption==naws.NAWS
+ oroption==MCCP
+ oroption==mssp.MSSP
+ oroption==suppress_ga.SUPPRESS_GA
+ )
[docs]defenableLocal(self,option):
+ """
+ Call to allow the activation of options for this protocol
+
+ Args:
+ option (char): The telnet option to enable locally.
+
+ Returns:
+ enable (bool): If this option should be enabled.
+
+ """
+ return(
+ option==LINEMODE
+ oroption==MCCP
+ oroption==ECHO
+ oroption==suppress_ga.SUPPRESS_GA
+ )
[docs]defconnectionLost(self,reason):
+ """
+ this is executed when the connection is lost for whatever
+ reason. it can also be called directly, from the disconnect
+ method
+
+ Args:
+ reason (str): Motivation for losing connection.
+
+ """
+ self.sessionhandler.disconnect(self)
+ self.transport.loseConnection()
+
+
[docs]defapplicationDataReceived(self,data):
+ """
+ Telnet method called when non-telnet-command data is coming in
+ over the telnet connection. We pass it on to the game engine
+ directly.
+
+ Args:
+ data (str): Incoming data.
+
+ """
+ ifnotdata:
+ data=[data]
+ elifdata.strip()==NULL:
+ # this is an ancient type of keepalive used by some
+ # legacy clients. There should never be a reason to send a
+ # lone NULL character so this seems to be a safe thing to
+ # support for backwards compatibility. It also stops the
+ # NULL from continuously popping up as an unknown command.
+ data=[_IDLE_COMMAND]
+ else:
+ data=_RE_LINEBREAK.split(data)
+
+ iflen(data)>2and_HTTP_REGEX.match(data[0]):
+ # guard against HTTP request on the Telnet port; we
+ # block and kill the connection.
+ self.transport.write(_HTTP_WARNING)
+ self.transport.loseConnection()
+ return
+
+ ifself.line_bufferandlen(data)>1:
+ # buffer exists, it is terminated by the first line feed
+ data[0]=self.line_buffer+data[0]
+ self.line_buffer=b""
+ # if the last data split is empty, it means all splits have
+ # line breaks, if not, it is unterminated and must be
+ # buffered.
+ self.line_buffer+=data.pop()
+ # send all data chunks
+ fordatindata:
+ self.data_in(text=dat+b"\n")
+
+ def_write(self,data):
+ """hook overloading the one used in plain telnet"""
+ data=data.replace(b"\n",b"\r\n").replace(b"\r\r\n",b"\r\n")
+ super()._write(mccp_compress(self,data))
+
+
[docs]defsendLine(self,line):
+ """
+ Hook overloading the one used by linereceiver.
+
+ Args:
+ line (str): Line to send.
+
+ """
+ line=to_bytes(line,self)
+ # escape IAC in line mode, and correctly add \r\n (the TELNET end-of-line)
+ line=line.replace(IAC,IAC+IAC)
+ line=line.replace(b"\n",b"\r\n")
+ ifnotline.endswith(b"\r\n")andself.protocol_flags.get("FORCEDENDLINE",True):
+ line+=b"\r\n"
+ ifnotself.protocol_flags.get("NOGOAHEAD",True):
+ line+=IAC+GA
+ returnself.transport.write(mccp_compress(self,line))
+
+ # Session hooks
+
+
[docs]defdisconnect(self,reason=""):
+ """
+ generic hook for the engine to call in order to
+ disconnect this protocol.
+
+ Args:
+ reason (str, optional): Reason for disconnecting.
+
+ """
+ self.data_out(text=((reason,),{}))
+ self.connectionLost(reason)
+
+
[docs]defdata_in(self,**kwargs):
+ """
+ Data User -> Evennia
+
+ Keyword Args:
+ kwargs (any): Options from the protocol.
+
+ """
+ # from evennia.server.profiling.timetrace import timetrace # DEBUG
+ # text = timetrace(text, "telnet.data_in") # DEBUG
+
+ self.sessionhandler.data_in(self,**kwargs)
+
+
[docs]defdata_out(self,**kwargs):
+ """
+ Data Evennia -> User
+
+ Keyword Args:
+ kwargs (any): Options to the protocol
+ """
+ self.sessionhandler.data_out(self,**kwargs)
+
+ # send_* methods
+
+
[docs]defsend_text(self,*args,**kwargs):
+ """
+ Send text data. This is an in-band telnet operation.
+
+ Args:
+ text (str): The first argument is always the text string to send. No other arguments
+ are considered.
+ Keyword Args:
+ options (dict): Send-option flags:
+
+ - mxp: Enforce MXP link support.
+ - ansi: Enforce no ANSI colors.
+ - xterm256: Enforce xterm256 colors, regardless of TTYPE.
+ - noxterm256: Enforce no xterm256 color support, regardless of TTYPE.
+ - nocolor: Strip all Color, regardless of ansi/xterm256 setting.
+ - raw: Pass string through without any ansi processing
+ (i.e. include Evennia ansi markers but do not
+ convert them into ansi tokens)
+ - echo: Turn on/off line echo on the client. Turn
+ off line echo for client, for example for password.
+ Note that it must be actively turned back on again!
+
+ """
+ text=args[0]ifargselse""
+ iftextisNone:
+ return
+
+ # handle arguments
+ options=kwargs.get("options",{})
+ flags=self.protocol_flags
+ xterm256=options.get(
+ "xterm256",flags.get("XTERM256",False)ifflags.get("TTYPE",False)elseTrue
+ )
+ useansi=options.get(
+ "ansi",flags.get("ANSI",False)ifflags.get("TTYPE",False)elseTrue
+ )
+ raw=options.get("raw",flags.get("RAW",False))
+ nocolor=options.get("nocolor",flags.get("NOCOLOR")ornot(xterm256oruseansi))
+ echo=options.get("echo",None)
+ mxp=options.get("mxp",flags.get("MXP",False))
+ screenreader=options.get("screenreader",flags.get("SCREENREADER",False))
+
+ ifscreenreader:
+ # screenreader mode cleans up output
+ text=ansi.parse_ansi(text,strip_ansi=True,xterm256=False,mxp=False)
+ text=_RE_SCREENREADER_REGEX.sub("",text)
+
+ ifoptions.get("send_prompt"):
+ # send a prompt instead.
+ prompt=text
+ ifnotraw:
+ # processing
+ prompt=ansi.parse_ansi(
+ _RE_N.sub("",prompt)+("||n"ifprompt.endswith("|")else"|n"),
+ strip_ansi=nocolor,
+ xterm256=xterm256,
+ )
+ ifmxp:
+ prompt=mxp_parse(prompt)
+ prompt=to_bytes(prompt,self)
+ prompt=prompt.replace(IAC,IAC+IAC).replace(b"\n",b"\r\n")
+ ifnotself.protocol_flags.get("NOPROMPTGOAHEAD",
+ self.protocol_flags.get("NOGOAHEAD",True)):
+ prompt+=IAC+GA
+ self.transport.write(mccp_compress(self,prompt))
+ else:
+ ifechoisnotNone:
+ # turn on/off echo. Note that this is a bit turned around since we use
+ # echo as if we are "turning off the client's echo" when telnet really
+ # handles it the other way around.
+ ifecho:
+ # by telling the client that WE WON'T echo, the client knows
+ # that IT should echo. This is the expected behavior from
+ # our perspective.
+ self.transport.write(mccp_compress(self,IAC+WONT+ECHO))
+ else:
+ # by telling the client that WE WILL echo, the client can
+ # safely turn OFF its OWN echo.
+ self.transport.write(mccp_compress(self,IAC+WILL+ECHO))
+ ifraw:
+ # no processing
+ self.sendLine(text)
+ return
+ else:
+ # we need to make sure to kill the color at the end in order
+ # to match the webclient output.
+ linetosend=ansi.parse_ansi(
+ _RE_N.sub("",text)+("||n"iftext.endswith("|")else"|n"),
+ strip_ansi=nocolor,
+ xterm256=xterm256,
+ mxp=mxp,
+ )
+ ifmxp:
+ linetosend=mxp_parse(linetosend)
+ self.sendLine(linetosend)
+
+
[docs]defsend_prompt(self,*args,**kwargs):
+ """
+ Send a prompt - a text without a line end. See send_text for argument options.
+
+ """
+ kwargs["options"].update({"send_prompt":True})
+ self.send_text(*args,**kwargs)
+
+
[docs]defsend_default(self,cmdname,*args,**kwargs):
+ """
+ Send other oob data
+ """
+ ifnotcmdname=="options":
+ self.oob.data_out(cmdname,*args,**kwargs)
+"""
+
+Telnet OOB (Out of band communication)
+
+OOB protocols allow for asynchronous communication between Evennia and
+compliant telnet clients. The "text" type of send command will always
+be sent "in-band", appearing in the client's main text output. OOB
+commands, by contrast, can have many forms and it is up to the client
+how and if they are handled. Examples of OOB instructions could be to
+instruct the client to play sounds or to update a graphical health
+bar.
+
+> Note that in Evennia's Web client, all send commands are "OOB commands",
+(including the "text" one), there is no equivalence to MSDP/GMCP for the
+webclient since it doesn't need it.
+
+This implements the following telnet OOB communication protocols:
+
+- MSDP (Mud Server Data Protocol), as per
+ http://tintin.sourceforge.net/msdp/
+- GMCP (Generic Mud Communication Protocol) as per
+ http://www.ironrealms.com/rapture/manual/files/FeatGMCP-txt.html#Generic_MUD_Communication_Protocol%28GMCP%29
+
+Following the lead of KaVir's protocol snippet, we first check if client
+supports MSDP and if not, we fallback to GMCP with a MSDP header where
+applicable.
+
+----
+
+"""
+importre
+importjson
+fromevennia.utils.utilsimportis_iter
+
+# General Telnet
+fromtwisted.conch.telnetimportIAC,SB,SE
+
+# MSDP-relevant telnet cmd/opt-codes
+MSDP=bytes([69])
+MSDP_VAR=bytes([1])
+MSDP_VAL=bytes([2])
+MSDP_TABLE_OPEN=bytes([3])
+MSDP_TABLE_CLOSE=bytes([4])
+
+MSDP_ARRAY_OPEN=bytes([5])
+MSDP_ARRAY_CLOSE=bytes([6])
+
+# GMCP
+GMCP=bytes([201])
+
+
+# pre-compiled regexes
+# returns 2-tuple
+msdp_regex_table=re.compile(
+ br"%s\s*(\w*?)\s*%s\s*%s(.*?)%s"%(MSDP_VAR,MSDP_VAL,MSDP_TABLE_OPEN,MSDP_TABLE_CLOSE)
+)
+# returns 2-tuple
+msdp_regex_array=re.compile(
+ br"%s\s*(\w*?)\s*%s\s*%s(.*?)%s"%(MSDP_VAR,MSDP_VAL,MSDP_ARRAY_OPEN,MSDP_ARRAY_CLOSE)
+)
+msdp_regex_var=re.compile(br"%s"%MSDP_VAR)
+msdp_regex_val=re.compile(br"%s"%MSDP_VAL)
+
+EVENNIA_TO_GMCP={
+ "client_options":"Core.Supports.Get",
+ "get_inputfuncs":"Core.Commands.Get",
+ "get_value":"Char.Value.Get",
+ "repeat":"Char.Repeat.Update",
+ "monitor":"Char.Monitor.Update",
+}
+
+
+# MSDP/GMCP communication handler
+
+
+
[docs]classTelnetOOB(object):
+ """
+ Implements the MSDP and GMCP protocols.
+ """
+
+
[docs]def__init__(self,protocol):
+ """
+ Initiates by storing the protocol on itself and trying to
+ determine if the client supports MSDP.
+
+ Args:
+ protocol (Protocol): The active protocol.
+
+ """
+ self.protocol=protocol
+ self.protocol.protocol_flags["OOB"]=False
+ self.MSDP=False
+ self.GMCP=False
+ # ask for the available protocols and assign decoders
+ # (note that handshake_done() will be called twice!)
+ self.protocol.negotiationMap[MSDP]=self.decode_msdp
+ self.protocol.negotiationMap[GMCP]=self.decode_gmcp
+ self.protocol.will(MSDP).addCallbacks(self.do_msdp,self.no_msdp)
+ self.protocol.will(GMCP).addCallbacks(self.do_gmcp,self.no_gmcp)
+ self.oob_reported={}
+
+
[docs]defno_msdp(self,option):
+ """
+ Client reports No msdp supported or wanted.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ # no msdp, check GMCP
+ self.protocol.handshake_done()
+
+
[docs]defdo_msdp(self,option):
+ """
+ Client reports that it supports msdp.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.MSDP=True
+ self.protocol.protocol_flags["OOB"]=True
+ self.protocol.handshake_done()
+
+
[docs]defno_gmcp(self,option):
+ """
+ If this is reached, it means neither MSDP nor GMCP is
+ supported.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.protocol.handshake_done()
+
+
[docs]defdo_gmcp(self,option):
+ """
+ Called when client confirms that it can do MSDP or GMCP.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.GMCP=True
+ self.protocol.protocol_flags["OOB"]=True
+ self.protocol.handshake_done()
+
+ # encoders
+
+
[docs]defencode_msdp(self,cmdname,*args,**kwargs):
+ """
+ Encode into a valid MSDP command.
+
+ Args:
+ cmdname (str): Name of send instruction.
+ args, kwargs (any): Arguments to OOB command.
+
+ Notes:
+ The output of this encoding will be
+ MSDP structures on these forms:
+ ::
+
+ [cmdname, [], {}] -> VAR cmdname VAL ""
+ [cmdname, [arg], {}] -> VAR cmdname VAL arg
+ [cmdname, [args],{}] -> VAR cmdname VAL ARRAYOPEN VAL arg VAL arg ... ARRAYCLOSE
+ [cmdname, [], {kwargs}] -> VAR cmdname VAL TABLEOPEN VAR key VAL val ... TABLECLOSE
+ [cmdname, [args], {kwargs}] -> VAR cmdname VAL ARRAYOPEN VAL arg VAL arg ... ARRAYCLOSE
+ VAR cmdname VAL TABLEOPEN VAR key VAL val ... TABLECLOSE
+
+ Further nesting is not supported, so if an array argument consists
+ of an array (for example), that array will be json-converted to a
+ string.
+
+ """
+ msdp_cmdname="{msdp_var}{msdp_cmdname}{msdp_val}".format(
+ msdp_var=MSDP_VAR.decode(),msdp_cmdname=cmdname,msdp_val=MSDP_VAL.decode()
+ )
+
+ ifnot(argsorkwargs):
+ returnmsdp_cmdname.encode()
+
+ # print("encode_msdp in:", cmdname, args, kwargs) # DEBUG
+
+ msdp_args=""
+ ifargs:
+ msdp_args=msdp_cmdname
+ iflen(args)==1:
+ msdp_args+=args[0]
+ else:
+ msdp_args+=(
+ "{msdp_array_open}"
+ "{msdp_args}"
+ "{msdp_array_close}".format(
+ msdp_array_open=MSDP_ARRAY_OPEN.decode(),
+ msdp_array_close=MSDP_ARRAY_CLOSE.decode(),
+ msdp_args="".join("%s%s"%(MSDP_VAL.decode(),val)forvalinargs),
+ )
+ )
+
+ msdp_kwargs=""
+ ifkwargs:
+ msdp_kwargs=msdp_cmdname
+ msdp_kwargs+=(
+ "{msdp_table_open}"
+ "{msdp_kwargs}"
+ "{msdp_table_close}".format(
+ msdp_table_open=MSDP_TABLE_OPEN.decode(),
+ msdp_table_close=MSDP_TABLE_CLOSE.decode(),
+ msdp_kwargs="".join(
+ "%s%s%s%s"%(MSDP_VAR.decode(),key,MSDP_VAL.decode(),val)
+ forkey,valinkwargs.items()
+ ),
+ )
+ )
+
+ msdp_string=msdp_args+msdp_kwargs
+
+ # print("msdp_string:", msdp_string) # DEBUG
+ returnmsdp_string.encode()
+
+
[docs]defencode_gmcp(self,cmdname,*args,**kwargs):
+ """
+ Encode into GMCP messages.
+
+ Args:
+ cmdname (str): GMCP OOB command name.
+ args, kwargs (any): Arguments to OOB command.
+
+ Notes:
+ GMCP messages will be outgoing on the following
+ form (the non-JSON cmdname at the start is what
+ IRE games use, supposedly, and what clients appear
+ to have adopted). A cmdname without Package will end
+ up in the Core package, while Core package names will
+ be stripped on the Evennia side.
+ ::
+
+ [cmd.name, [], {}] -> Cmd.Name
+ [cmd.name, [arg], {}] -> Cmd.Name arg
+ [cmd.name, [args],{}] -> Cmd.Name [args]
+ [cmd.name, [], {kwargs}] -> Cmd.Name {kwargs}
+ [cmdname, [args, {kwargs}] -> Core.Cmdname [[args],{kwargs}]
+
+ Notes:
+ There are also a few default mappings between evennia outputcmds and
+ GMCP:
+ ::
+
+ client_options -> Core.Supports.Get
+ get_inputfuncs -> Core.Commands.Get
+ get_value -> Char.Value.Get
+ repeat -> Char.Repeat.Update
+ monitor -> Char.Monitor.Update
+
+ """
+
+ ifcmdnameinEVENNIA_TO_GMCP:
+ gmcp_cmdname=EVENNIA_TO_GMCP[cmdname]
+ elif"_"incmdname:
+ gmcp_cmdname=".".join(word.capitalize()forwordincmdname.split("_"))
+ else:
+ gmcp_cmdname="Core.%s"%cmdname.capitalize()
+
+ ifnot(argsorkwargs):
+ gmcp_string=gmcp_cmdname
+ elifargs:
+ iflen(args)==1:
+ args=args[0]
+ ifkwargs:
+ gmcp_string="%s%s"%(gmcp_cmdname,json.dumps([args,kwargs]))
+ else:
+ gmcp_string="%s%s"%(gmcp_cmdname,json.dumps(args))
+ else:# only kwargs
+ gmcp_string="%s%s"%(gmcp_cmdname,json.dumps(kwargs))
+
+ # print("gmcp string", gmcp_string) # DEBUG
+ returngmcp_string.encode()
+
+
[docs]defdecode_msdp(self,data):
+ """
+ Decodes incoming MSDP data.
+
+ Args:
+ data (str or list): MSDP data.
+
+ Notes:
+ Clients should always send MSDP data on
+ one of the following forms:
+ ::
+
+ cmdname '' -> [cmdname, [], {}]
+ cmdname val -> [cmdname, [val], {}]
+ cmdname array -> [cmdname, [array], {}]
+ cmdname table -> [cmdname, [], {table}]
+ cmdname array cmdname table -> [cmdname, [array], {table}]
+
+ Observe that all MSDP_VARS are used to identify cmdnames,
+ so if there are multiple arrays with the same cmdname
+ given, they will be merged into one argument array, same
+ for tables. Different MSDP_VARS (outside tables) will be
+ identified as separate cmdnames.
+
+ """
+ ifisinstance(data,list):
+ data=b"".join(data)
+
+ # print("decode_msdp in:", data) # DEBUG
+
+ tables={}
+ arrays={}
+ variables={}
+
+ # decode tables
+ forkey,tableinmsdp_regex_table.findall(data):
+ key=key.decode()
+ tables[key]={}ifkeynotintableselsetables[key]
+ forvarvalinmsdp_regex_var.split(table)[1:]:
+ var,val=msdp_regex_val.split(varval,1)
+ var,val=var.decode(),val.decode()
+ ifvar:
+ tables[key][var]=val
+
+ # decode arrays from all that was not a table
+ data_no_tables=msdp_regex_table.sub(b"",data)
+ forkey,arrayinmsdp_regex_array.findall(data_no_tables):
+ key=key.decode()
+ arrays[key]=[]ifkeynotinarrayselsearrays[key]
+ parts=msdp_regex_val.split(array)
+ parts=[part.decode()forpartinparts]
+ iflen(parts)==2:
+ arrays[key].append(parts[1])
+ eliflen(parts)>1:
+ arrays[key].extend(parts[1:])
+
+ # decode remainders from all that were not tables or arrays
+ data_no_tables_or_arrays=msdp_regex_array.sub(b"",data_no_tables)
+ forvarvalinmsdp_regex_var.split(data_no_tables_or_arrays):
+ # get remaining varvals after cleaning away tables/arrays. If mathcing
+ # an existing key in arrays, it will be added as an argument to that command,
+ # otherwise it will be treated as a command without argument.
+ parts=msdp_regex_val.split(varval)
+ parts=[part.decode()forpartinparts]
+ iflen(parts)==2:
+ variables[parts[0]]=parts[1]
+ eliflen(parts)>1:
+ variables[parts[0]]=parts[1:]
+
+ cmds={}
+ # merge matching table/array/variables together
+ forkey,tableintables.items():
+ args,kwargs=[],table
+ ifkeyinarrays:
+ args.extend(arrays.pop(key))
+ ifkeyinvariables:
+ args.append(variables.pop(key))
+ cmds[key]=[args,kwargs]
+
+ forkey,arrinarrays.items():
+ args,kwargs=arr,{}
+ ifkeyinvariables:
+ args.append(variables.pop(key))
+ cmds[key]=[args,kwargs]
+
+ forkey,varinvariables.items():
+ cmds[key]=[[var],{}]
+
+ # remap the 'generic msdp commands' to avoid colliding with builtins etc
+ # by prepending "msdp_"
+ lower_case={key.lower():keyforkeyincmds}
+ forremapin("list","report","reset","send","unreport"):
+ ifremapinlower_case:
+ cmds["msdp_{}".format(remap)]=cmds.pop(lower_case[remap])
+
+ # print("msdp data in:", cmds) # DEBUG
+ self.protocol.data_in(**cmds)
+
+
[docs]defdecode_gmcp(self,data):
+ """
+ Decodes incoming GMCP data on the form 'varname <structure>'.
+
+ Args:
+ data (str or list): GMCP data.
+
+ Notes:
+ Clients send data on the form "Module.Submodule.Cmdname <structure>".
+ We assume the structure is valid JSON.
+
+ The following is parsed into Evennia's formal structure:
+ ::
+
+ Core.Name -> [name, [], {}]
+ Core.Name string -> [name, [string], {}]
+ Core.Name [arg, arg,...] -> [name, [args], {}]
+ Core.Name {key:arg, key:arg, ...} -> [name, [], {kwargs}]
+ Core.Name [[args], {kwargs}] -> [name, [args], {kwargs}]
+
+ """
+ ifisinstance(data,list):
+ data=b"".join(data)
+
+ # print("decode_gmcp in:", data) # DEBUG
+ ifdata:
+ try:
+ cmdname,structure=data.split(None,1)
+ exceptValueError:
+ cmdname,structure=data,b""
+ cmdname=cmdname.replace(b".",b"_")
+ try:
+ structure=json.loads(structure)
+ exceptValueError:
+ # maybe the structure is not json-serialized at all
+ pass
+ args,kwargs=[],{}
+ ifis_iter(structure):
+ ifisinstance(structure,dict):
+ kwargs={key:valueforkey,valueinstructure.items()ifkey}
+ else:
+ args=list(structure)
+ else:
+ args=(structure,)
+ ifcmdname.lower().startswith(b"core_"):
+ # if Core.cmdname, then use cmdname
+ cmdname=cmdname[5:]
+ self.protocol.data_in(**{cmdname.lower().decode():[args,kwargs]})
+
+ # access methods
+
+
[docs]defdata_out(self,cmdname,*args,**kwargs):
+ """
+ Return a MSDP- or GMCP-valid subnegotiation across the protocol.
+
+ Args:
+ cmdname (str): OOB-command name.
+ args, kwargs (any): Arguments to OOB command.
+
+ """
+ kwargs.pop("options",None)
+
+ ifself.MSDP:
+ encoded_oob=self.encode_msdp(cmdname,*args,**kwargs)
+ self.protocol._write(IAC+SB+MSDP+encoded_oob+IAC+SE)
+
+ ifself.GMCP:
+ encoded_oob=self.encode_gmcp(cmdname,*args,**kwargs)
+ self.protocol._write(IAC+SB+GMCP+encoded_oob+IAC+SE)
+"""
+This allows for running the telnet communication over an encrypted SSL tunnel. To use it, requires a
+client supporting Telnet SSL.
+
+The protocol will try to automatically create the private key and certificate on the server side
+when starting and will warn if this was not possible. These will appear as files ssl.key and
+ssl.cert in mygame/server/.
+
+"""
+importos
+
+try:
+ fromOpenSSLimportcrypto
+ fromtwisted.internetimportsslastwisted_ssl
+exceptImportErroraserror:
+ errstr="""
+{err}
+ Telnet-SSL requires the PyOpenSSL library and dependencies:
+
+ pip install pyopenssl pycrypto enum pyasn1 service_identity
+
+ Stop and start Evennia again. If no certificate can be generated, you'll
+ get a suggestion for a (linux) command to generate this locally.
+
+ """
+ raiseImportError(errstr.format(err=error))
+
+fromdjango.confimportsettings
+fromevennia.server.portal.telnetimportTelnetProtocol
+
+_GAME_DIR=settings.GAME_DIR
+
+_PRIVATE_KEY_LENGTH=2048
+_PRIVATE_KEY_FILE=os.path.join(_GAME_DIR,"server","ssl.key")
+_PUBLIC_KEY_FILE=os.path.join(_GAME_DIR,"server","ssl-public.key")
+_CERTIFICATE_FILE=os.path.join(_GAME_DIR,"server","ssl.cert")
+_CERTIFICATE_EXPIRE=365*24*60*60*20# 20 years
+_CERTIFICATE_ISSUER={
+ "C":"EV",
+ "ST":"Evennia",
+ "L":"Evennia",
+ "O":"Evennia Security",
+ "OU":"Evennia Department",
+ "CN":"evennia",
+}
+
+# messages
+
+NO_AUTOGEN="""
+Evennia could not auto-generate the SSL private- and public keys ({{err}}).
+If this error persists, create them manually (using the tools for your OS). The files
+should be placed and named like this:
+{}
+{}
+""".format(
+ _PRIVATE_KEY_FILE,_PUBLIC_KEY_FILE
+)
+
+NO_AUTOCERT="""
+Evennia's could not auto-generate the SSL certificate ({{err}}).
+The private key already exists here:
+{}
+If this error persists, create the certificate manually (using the private key and
+the tools for your OS). The file should be placed and named like this:
+{}
+""".format(
+ _PRIVATE_KEY_FILE,_CERTIFICATE_FILE
+)
+
+
+
[docs]classSSLProtocol(TelnetProtocol):
+ """
+ Communication is the same as telnet, except data transfer
+ is done with encryption set up by the portal at start time.
+ """
+
+
[docs]defgetSSLContext():
+ """
+ This is called by the portal when creating the SSL context
+ server-side.
+
+ Returns:
+ ssl_context (tuple): A key and certificate that is either
+ existing previously or created on the fly.
+
+ """
+
+ ifverify_or_create_SSL_key_and_cert(_PRIVATE_KEY_FILE,_CERTIFICATE_FILE):
+ returntwisted_ssl.DefaultOpenSSLContextFactory(_PRIVATE_KEY_FILE,_CERTIFICATE_FILE)
+ else:
+ returnNone
[docs]deftest_plain_ansi(self):
+ """
+ Test that printable characters do not get mangled.
+ """
+ irc_ansi=irc.parse_ansi_to_irc(string.printable)
+ ansi_irc=irc.parse_irc_to_ansi(string.printable)
+ self.assertEqual(irc_ansi,string.printable)
+ self.assertEqual(ansi_irc,string.printable)
[docs]deftest_identity(self):
+ """
+ Test that the composition of the function and
+ its inverse gives the correct string.
+ """
+
+ s=r"|wthis|Xis|gis|Ma|C|complex|*string"
+
+ self.assertEqual(irc.parse_irc_to_ansi(irc.parse_ansi_to_irc(s)),s)
+"""
+TTYPE (MTTS) - Mud Terminal Type Standard
+
+This module implements the TTYPE telnet protocol as per
+http://tintin.sourceforge.net/mtts/. It allows the server to ask the
+client about its capabilities. If the client also supports TTYPE, it
+will return with information such as its name, if it supports colour
+etc. If the client does not support TTYPE, this will be ignored.
+
+All data will be stored on the protocol's protocol_flags dictionary,
+under the 'TTYPE' key.
+"""
+# telnet option codes
+TTYPE=bytes([24])# b"\x18"
+IS=bytes([0])# b"\x00"
+SEND=bytes([1])# b"\x01"
+
+# terminal capabilities and their codes
+MTTS=[
+ (128,"PROXY"),
+ (64,"SCREENREADER"),
+ (32,"OSC_COLOR_PALETTE"),
+ (16,"MOUSE_TRACKING"),
+ (8,"XTERM256"),
+ (4,"UTF-8"),
+ (2,"VT100"),
+ (1,"ANSI"),
+]
+
+
+
[docs]classTtype(object):
+ """
+ Handles ttype negotiations. Called and initiated by the
+ telnet protocol.
+
+ """
+
+
[docs]def__init__(self,protocol):
+ """
+ Initialize ttype by storing protocol on ourselves and calling
+ the client to see if it supporst ttype.
+
+ Args:
+ protocol (Protocol): The protocol instance.
+
+ Notes:
+ The `self.ttype_step` indicates how far in the data
+ retrieval we've gotten.
+
+ """
+ self.ttype_step=0
+ self.protocol=protocol
+ # we set FORCEDENDLINE for clients not supporting ttype
+ self.protocol.protocol_flags["FORCEDENDLINE"]=True
+ self.protocol.protocol_flags["TTYPE"]=False
+ # is it a safe bet to assume ANSI is always supported?
+ self.protocol.protocol_flags["ANSI"]=True
+ # setup protocol to handle ttype initialization and negotiation
+ self.protocol.negotiationMap[TTYPE]=self.will_ttype
+ # ask if client will ttype, connect callback if it does.
+ self.protocol.do(TTYPE).addCallbacks(self.will_ttype,self.wont_ttype)
+
+
[docs]defwont_ttype(self,option):
+ """
+ Callback if ttype is not supported by client.
+
+ Args:
+ option (Option): Not used.
+
+ """
+ self.protocol.protocol_flags["TTYPE"]=False
+ self.protocol.handshake_done()
+
+
[docs]defwill_ttype(self,option):
+ """
+ Handles negotiation of the ttype protocol once the client has
+ confirmed that it will respond with the ttype protocol.
+
+ Args:
+ option (Option): Not used.
+
+ Notes:
+ The negotiation proceeds in several steps, each returning a
+ certain piece of information about the client. All data is
+ stored on protocol.protocol_flags under the TTYPE key.
+
+ """
+ options=self.protocol.protocol_flags
+
+ ifoptionsandoptions.get("TTYPE",False)orself.ttype_step>3:
+ return
+
+ try:
+ option=b"".join(option).lstrip(IS).decode()
+ exceptTypeError:
+ # option is not on a suitable form for joining
+ pass
+
+ ifself.ttype_step==0:
+ # just start the request chain
+ self.protocol.requestNegotiation(TTYPE,SEND)
+
+ elifself.ttype_step==1:
+ # this is supposed to be the name of the client/terminal.
+ # For clients not supporting the extended TTYPE
+ # definition, subsequent calls will just repeat-return this.
+ try:
+ clientname=option.upper()
+ exceptAttributeError:
+ # malformed option (not a string)
+ clientname="UNKNOWN"
+
+ # use name to identify support for xterm256. Many of these
+ # only support after a certain version, but all support
+ # it since at least 4 years. We assume recent client here for now.
+ xterm256=False
+ ifclientname.startswith("MUDLET"):
+ # supports xterm256 stably since 1.1 (2010?)
+ xterm256=clientname.split("MUDLET",1)[1].strip()>="1.1"
+ # Mudlet likes GA's on a prompt line for the prompt trigger to
+ # match, if it's not wanting NOGOAHEAD.
+ ifnotself.protocol.protocol_flags["NOGOAHEAD"]:
+ self.protocol.protocol_flags["NOGOAHEAD"]=True
+ self.protocol.protocol_flags["NOPROMPTGOAHEAD"]=False
+
+ if(
+ clientname.startswith("XTERM")
+ orclientname.endswith("-256COLOR")
+ orclientname
+ in(
+ "ATLANTIS",# > 0.9.9.0 (aug 2009)
+ "CMUD",# > 3.04 (mar 2009)
+ "KILDCLIENT",# > 2.2.0 (sep 2005)
+ "MUDLET",# > beta 15 (sep 2009)
+ "MUSHCLIENT",# > 4.02 (apr 2007)
+ "PUTTY",# > 0.58 (apr 2005)
+ "BEIP",# > 2.00.206 (late 2009) (BeipMu)
+ "POTATO",# > 2.00 (maybe earlier)
+ "TINYFUGUE",# > 4.x (maybe earlier)
+ )
+ ):
+ xterm256=True
+
+ # all clients supporting TTYPE at all seem to support ANSI
+ self.protocol.protocol_flags["ANSI"]=True
+ self.protocol.protocol_flags["XTERM256"]=xterm256
+ self.protocol.protocol_flags["CLIENTNAME"]=clientname
+ self.protocol.requestNegotiation(TTYPE,SEND)
+
+ elifself.ttype_step==2:
+ # this is a term capabilities flag
+ term=option
+ tupper=term.upper()
+ # identify xterm256 based on flag
+ xterm256=(
+ tupper.endswith("-256COLOR")
+ ortupper.endswith("XTERM")# Apple Terminal, old Tintin
+ andnottupper.endswith("-COLOR")# old Tintin, Putty
+ )
+ ifxterm256:
+ self.protocol.protocol_flags["ANSI"]=True
+ self.protocol.protocol_flags["XTERM256"]=xterm256
+ self.protocol.protocol_flags["TERM"]=term
+ # request next information
+ self.protocol.requestNegotiation(TTYPE,SEND)
+
+ elifself.ttype_step==3:
+ # the MTTS bitstring identifying term capabilities
+ ifoption.startswith("MTTS"):
+ option=option[4:].strip()
+ ifoption.isdigit():
+ # a number - determine the actual capabilities
+ option=int(option)
+ support=dict(
+ (capability,True)forbitval,capabilityinMTTSifoption&bitval>0
+ )
+ self.protocol.protocol_flags.update(support)
+ else:
+ # some clients send erroneous MTTS as a string. Add directly.
+ self.protocol.protocol_flags[option.upper()]=True
+
+ self.protocol.protocol_flags["TTYPE"]=True
+ # we must sync ttype once it'd done
+ self.protocol.handshake_done()
+ self.ttype_step+=1
+"""
+Webclient based on websockets.
+
+This implements a webclient with WebSockets (http://en.wikipedia.org/wiki/WebSocket)
+by use of the autobahn-python package's implementation (https://github.com/crossbario/autobahn-python).
+It is used together with evennia/web/media/javascript/evennia_websocket_webclient.js.
+
+All data coming into the webclient is in the form of valid JSON on the form
+
+`["inputfunc_name", [args], {kwarg}]`
+
+which represents an "inputfunc" to be called on the Evennia side with *args, **kwargs.
+The most common inputfunc is "text", which takes just the text input
+from the command line and interprets it as an Evennia Command: `["text", ["look"], {}]`
+
+"""
+importre
+importjson
+importhtml
+fromtwisted.internet.protocolimportProtocol
+fromdjango.confimportsettings
+fromevennia.server.sessionimportSession
+fromevennia.utils.utilsimportto_str,mod_import
+fromevennia.utils.ansiimportparse_ansi
+fromevennia.utils.text2htmlimportparse_html
+fromautobahn.twisted.websocketimportWebSocketServerProtocol
+fromautobahn.exceptionimportDisconnected
+
+_RE_SCREENREADER_REGEX=re.compile(
+ r"%s"%settings.SCREENREADER_REGEX_STRIP,re.DOTALL+re.MULTILINE
+)
+_CLIENT_SESSIONS=mod_import(settings.SESSION_ENGINE).SessionStore
+_UPSTREAM_IPS=settings.UPSTREAM_IPS
+
+# Status Code 1000: Normal Closure
+# called when the connection was closed through JavaScript
+CLOSE_NORMAL=WebSocketServerProtocol.CLOSE_STATUS_CODE_NORMAL
+
+# Status Code 1001: Going Away
+# called when the browser is navigating away from the page
+GOING_AWAY=WebSocketServerProtocol.CLOSE_STATUS_CODE_GOING_AWAY
+
+STATE_CLOSING=WebSocketServerProtocol.STATE_CLOSING
+
+
+
[docs]classWebSocketClient(WebSocketServerProtocol,Session):
+ """
+ Implements the server-side of the Websocket connection.
+ """
+
+ # nonce value, used to prevent the webclient from erasing the
+ # webclient_authenticated_uid value of csession on disconnect
+ nonce=None
+
+
[docs]defget_client_session(self):
+ """
+ Get the Client browser session (used for auto-login based on browser session)
+
+ Returns:
+ csession (ClientSession): This is a django-specific internal representation
+ of the browser session.
+
+ """
+ try:
+ self.csessid=self.http_request_uri.split("?",1)[1]
+ exceptIndexError:
+ # this may happen for custom webclients not caring for the
+ # browser session.
+ self.csessid=None
+ returnNone
+ exceptAttributeError:
+ fromevennia.utilsimportlogger
+
+ self.csessid=None
+ logger.log_trace(str(self))
+ returnNone
+ ifself.csessid:
+ return_CLIENT_SESSIONS(session_key=self.csessid)
+
+
[docs]defonOpen(self):
+ """
+ This is called when the WebSocket connection is fully established.
+
+ """
+ client_address=self.transport.client
+ client_address=client_address[0]ifclient_addresselseNone
+
+ ifclient_addressin_UPSTREAM_IPSand"x-forwarded-for"inself.http_headers:
+ addresses=[x.strip()forxinself.http_headers["x-forwarded-for"].split(",")]
+ addresses.reverse()
+
+ foraddrinaddresses:
+ ifaddrnotin_UPSTREAM_IPS:
+ client_address=addr
+ break
+
+ self.init_session("websocket",client_address,self.factory.sessionhandler)
+
+ csession=self.get_client_session()# this sets self.csessid
+ csessid=self.csessid
+ uid=csessionandcsession.get("webclient_authenticated_uid",None)
+ nonce=csessionandcsession.get("webclient_authenticated_nonce",0)
+ ifuid:
+ # the client session is already logged in.
+ self.uid=uid
+ self.nonce=nonce
+ self.logged_in=True
+
+ forold_sessioninself.sessionhandler.sessions_from_csessid(csessid):
+ if(
+ hasattr(old_session,"websocket_close_code")
+ andold_session.websocket_close_code!=CLOSE_NORMAL
+ ):
+ # if we have old sessions with the same csession, they are remnants
+ self.sessid=old_session.sessid
+ self.sessionhandler.disconnect(old_session)
+
+ self.protocol_flags["CLIENTNAME"]="Evennia Webclient (websocket)"
+ self.protocol_flags["UTF-8"]=True
+ self.protocol_flags["OOB"]=True
+
+ # watch for dead links
+ self.transport.setTcpKeepAlive(1)
+ # actually do the connection
+ self.sessionhandler.connect(self)
+
+
[docs]defdisconnect(self,reason=None):
+ """
+ Generic hook for the engine to call in order to
+ disconnect this protocol.
+
+ Args:
+ reason (str or None): Motivation for the disconnection.
+
+ """
+ csession=self.get_client_session()
+
+ ifcsession:
+ # if the nonce is different, webclient_authenticated_uid has been
+ # set *before* this disconnect (disconnect called after a new client
+ # connects, which occurs in some 'fast' browsers like Google Chrome
+ # and Mobile Safari)
+ ifcsession.get("webclient_authenticated_nonce",None)==self.nonce:
+ csession["webclient_authenticated_uid"]=None
+ csession["webclient_authenticated_nonce"]=0
+ csession.save()
+ self.logged_in=False
+
+ self.sessionhandler.disconnect(self)
+ # autobahn-python:
+ # 1000 for a normal close, 1001 if the browser window is closed,
+ # 3000-4999 for app. specific,
+ # in case anyone wants to expose this functionality later.
+ #
+ # sendClose() under autobahn/websocket/interfaces.py
+ ret=self.sendClose(CLOSE_NORMAL,reason)
+
+
[docs]defonClose(self,wasClean,code=None,reason=None):
+ """
+ This is executed when the connection is lost for whatever
+ reason. it can also be called directly, from the disconnect
+ method.
+
+ Args:
+ wasClean (bool): ``True`` if the WebSocket was closed cleanly.
+ code (int or None): Close status as sent by the WebSocket peer.
+ reason (str or None): Close reason as sent by the WebSocket peer.
+
+ """
+ ifcode==CLOSE_NORMALorcode==GOING_AWAY:
+ self.disconnect(reason)
+ else:
+ self.websocket_close_code=code
+
+
[docs]defonMessage(self,payload,isBinary):
+ """
+ Callback fired when a complete WebSocket message was received.
+
+ Args:
+ payload (bytes): The WebSocket message received.
+ isBinary (bool): Flag indicating whether payload is binary or
+ UTF-8 encoded text.
+
+ """
+ cmdarray=json.loads(str(payload,"utf-8"))
+ ifcmdarray:
+ self.data_in(**{cmdarray[0]:[cmdarray[1],cmdarray[2]]})
+
+
[docs]defsendLine(self,line):
+ """
+ Send data to client.
+
+ Args:
+ line (str): Text to send.
+
+ """
+ try:
+ returnself.sendMessage(line.encode())
+ exceptDisconnected:
+ # this can happen on an unclean close of certain browsers.
+ # it means this link is actually already closed.
+ self.disconnect(reason="Browser already closed.")
[docs]defdata_in(self,**kwargs):
+ """
+ Data User > Evennia.
+
+ Args:
+ text (str): Incoming text.
+ kwargs (any): Options from protocol.
+
+ Notes:
+ At initilization, the client will send the special
+ 'csessid' command to identify its browser session hash
+ with the Evennia side.
+
+ The websocket client will also pass 'websocket_close' command
+ to report that the client has been closed and that the
+ session should be disconnected.
+
+ Both those commands are parsed and extracted already at
+ this point.
+
+ """
+ if"websocket_close"inkwargs:
+ self.disconnect()
+ return
+
+ self.sessionhandler.data_in(self,**kwargs)
+
+
[docs]defsend_text(self,*args,**kwargs):
+ """
+ Send text data. This will pre-process the text for
+ color-replacement, conversion to html etc.
+
+ Args:
+ text (str): Text to send.
+
+ Keyword Args:
+ options (dict): Options-dict with the following keys understood:
+ - raw (bool): No parsing at all (leave ansi-to-html markers unparsed).
+ - nocolor (bool): Clean out all color.
+ - screenreader (bool): Use Screenreader mode.
+ - send_prompt (bool): Send a prompt with parsed html
+
+ """
+ ifargs:
+ args=list(args)
+ text=args[0]
+ iftextisNone:
+ return
+ else:
+ return
+ # just to be sure
+ text=to_str(text)
+
+ flags=self.protocol_flags
+
+ options=kwargs.pop("options",{})
+ raw=options.get("raw",flags.get("RAW",False))
+ client_raw=options.get("client_raw",False)
+ nocolor=options.get("nocolor",flags.get("NOCOLOR",False))
+ screenreader=options.get("screenreader",flags.get("SCREENREADER",False))
+ prompt=options.get("send_prompt",False)
+
+ ifscreenreader:
+ # screenreader mode cleans up output
+ text=parse_ansi(text,strip_ansi=True,xterm256=False,mxp=False)
+ text=_RE_SCREENREADER_REGEX.sub("",text)
+ cmd="prompt"ifpromptelse"text"
+ ifraw:
+ ifclient_raw:
+ args[0]=text
+ else:
+ args[0]=html.escape(text)# escape html!
+ else:
+ args[0]=parse_html(text,strip_ansi=nocolor)
+
+ # send to client on required form [cmdname, args, kwargs]
+ self.sendLine(json.dumps([cmd,args,kwargs]))
[docs]defsend_default(self,cmdname,*args,**kwargs):
+ """
+ Data Evennia -> User.
+
+ Args:
+ cmdname (str): The first argument will always be the oob cmd name.
+ *args (any): Remaining args will be arguments for `cmd`.
+
+ Keyword Args:
+ options (dict): These are ignored for oob commands. Use command
+ arguments (which can hold dicts) to send instructions to the
+ client instead.
+
+ """
+ ifnotcmdname=="options":
+ self.sendLine(json.dumps([cmdname,args,kwargs]))
Source code for evennia.server.portal.webclient_ajax
+"""
+AJAX/COMET fallback webclient
+
+The AJAX/COMET web client consists of two components running on
+twisted and django. They are both a part of the Evennia website url
+tree (so the testing website might be located on
+http://localhost:4001/, whereas the webclient can be found on
+http://localhost:4001/webclient.)
+
+/webclient - this url is handled through django's template
+ system and serves the html page for the client
+ itself along with its javascript chat program.
+/webclientdata - this url is called by the ajax chat using
+ POST requests (long-polling when necessary)
+ The WebClient resource in this module will
+ handle these requests and act as a gateway
+ to sessions connected over the webclient.
+"""
+importjson
+importre
+importtime
+importhtml
+
+fromtwisted.webimportserver,resource
+fromtwisted.internet.taskimportLoopingCall
+fromdjango.utils.functionalimportPromise
+fromdjango.confimportsettings
+fromevennia.utils.ansiimportparse_ansi
+fromevennia.utilsimportutils
+fromevennia.utils.utilsimportto_bytes,to_str
+fromevennia.utils.text2htmlimportparse_html
+fromevennia.serverimportsession
+
+_CLIENT_SESSIONS=utils.mod_import(settings.SESSION_ENGINE).SessionStore
+_RE_SCREENREADER_REGEX=re.compile(
+ r"%s"%settings.SCREENREADER_REGEX_STRIP,re.DOTALL+re.MULTILINE
+)
+_SERVERNAME=settings.SERVERNAME
+_KEEPALIVE=30# how often to check keepalive
+
+
+# defining a simple json encoder for returning
+# django data to the client. Might need to
+# extend this if one wants to send more
+# complex database objects too.
+
+
+
+
+ def_responseFailed(self,failure,csessid,request):
+ "callback if a request is lost/timed out"
+ try:
+ delself.requests[csessid]
+ exceptKeyError:
+ # nothing left to delete
+ pass
+
+ def_keepalive(self):
+ """
+ Callback for checking the connection is still alive.
+ """
+ now=time.time()
+ to_remove=[]
+ keep_alives=(
+ (csessid,remove)
+ forcsessid,(t,remove)inself.last_alive.items()
+ ifnow-t>_KEEPALIVE
+ )
+ forcsessid,removeinkeep_alives:
+ ifremove:
+ # keepalive timeout. Line is dead.
+ to_remove.append(csessid)
+ else:
+ # normal timeout - send keepalive
+ self.last_alive[csessid]=(now,True)
+ self.lineSend(csessid,["ajax_keepalive",[],{}])
+ # remove timed-out sessions
+ forcsessidinto_remove:
+ sessions=self.sessionhandler.sessions_from_csessid(csessid)
+ forsessinsessions:
+ sess.disconnect()
+ self.last_alive.pop(csessid,None)
+ ifnotself.last_alive:
+ # no more ajax clients. Stop the keepalive
+ self.keep_alive.stop()
+ self.keep_alive=None
+
+
[docs]defget_client_sessid(self,request):
+ """
+ Helper to get the client session id out of the request.
+
+ Args:
+ request (Request): Incoming request object.
+ Returns:
+ csessid (int): The client-session id.
+
+ """
+ returnhtml.escape(request.args[b"csessid"][0].decode("utf-8"))
+
+
[docs]defat_login(self):
+ """
+ Called when this session gets authenticated by the server.
+ """
+ pass
+
+
[docs]deflineSend(self,csessid,data):
+ """
+ This adds the data to the buffer and/or sends it to the client
+ as soon as possible.
+
+ Args:
+ csessid (int): Session id.
+ data (list): A send structure [cmdname, [args], {kwargs}].
+
+ """
+ request=self.requests.get(csessid)
+ ifrequest:
+ # we have a request waiting. Return immediately.
+ request.write(jsonify(data))
+ request.finish()
+ delself.requests[csessid]
+ else:
+ # no waiting request. Store data in buffer
+ dataentries=self.databuffer.get(csessid,[])
+ dataentries.append(jsonify(data))
+ self.databuffer[csessid]=dataentries
[docs]defmode_init(self,request):
+ """
+ This is called by render_POST when the client requests an init
+ mode operation (at startup)
+
+ Args:
+ request (Request): Incoming request.
+
+ """
+ csessid=self.get_client_sessid(request)
+
+ remote_addr=request.getClientIP()
+
+ ifremote_addrinsettings.UPSTREAM_IPSandrequest.getHeader("x-forwarded-for"):
+ addresses=[x.strip()forxinrequest.getHeader("x-forwarded-for").split(",")]
+ addresses.reverse()
+
+ foraddrinaddresses:
+ ifaddrnotinsettings.UPSTREAM_IPS:
+ remote_addr=addr
+ break
+
+ host_string="%s (%s:%s)"%(
+ _SERVERNAME,
+ request.getRequestHostname(),
+ request.getHost().port,
+ )
+
+ sess=AjaxWebClientSession()
+ sess.client=self
+ sess.init_session("ajax/comet",remote_addr,self.sessionhandler)
+
+ sess.csessid=csessid
+ csession=_CLIENT_SESSIONS(session_key=sess.csessid)
+ uid=csessionandcsession.get("webclient_authenticated_uid",False)
+ ifuid:
+ # the client session is already logged in
+ sess.uid=uid
+ sess.logged_in=True
+
+ # watch for dead links
+ self.last_alive[csessid]=(time.time(),False)
+ ifnotself.keep_alive:
+ # the keepalive is not running; start it.
+ self.keep_alive=LoopingCall(self._keepalive)
+ self.keep_alive.start(_KEEPALIVE,now=False)
+
+ # actually do the connection
+ sess.sessionhandler.connect(sess)
+
+ returnjsonify({"msg":host_string,"csessid":csessid})
+
+
[docs]defmode_keepalive(self,request):
+
+ """
+ This is called by render_POST when the
+ client is replying to the keepalive.
+ """
+ csessid=self.get_client_sessid(request)
+ self.last_alive[csessid]=(time.time(),False)
+ returnb'""'
+
+
[docs]defmode_input(self,request):
+ """
+ This is called by render_POST when the client
+ is sending data to the server.
+
+ Args:
+ request (Request): Incoming request.
+
+ """
+ csessid=self.get_client_sessid(request)
+ self.last_alive[csessid]=(time.time(),False)
+ cmdarray=json.loads(request.args.get(b"data")[0])
+ forsessinself.sessionhandler.sessions_from_csessid(csessid):
+ sess.data_in(**{cmdarray[0]:[cmdarray[1],cmdarray[2]]})
+ returnb'""'
+
+
[docs]defmode_receive(self,request):
+ """
+ This is called by render_POST when the client is telling us
+ that it is ready to receive data as soon as it is available.
+ This is the basis of a long-polling (comet) mechanism: the
+ server will wait to reply until data is available.
+
+ Args:
+ request (Request): Incoming request.
+
+ """
+ csessid=html.escape(request.args[b"csessid"][0].decode("utf-8"))
+ self.last_alive[csessid]=(time.time(),False)
+
+ dataentries=self.databuffer.get(csessid)
+ ifdataentries:
+ # we have data that could not be sent earlier (because client was not
+ # ready to receive it). Return this buffered data immediately
+ returndataentries.pop(0)
+ else:
+ # we have no data to send. End the old request and start
+ # a new long-polling one
+ request.notifyFinish().addErrback(self._responseFailed,csessid,request)
+ ifcsessidinself.requests:
+ self.requests[csessid].finish()# Clear any stale request.
+ self.requests[csessid]=request
+ returnserver.NOT_DONE_YET
+
+
[docs]defmode_close(self,request):
+ """
+ This is called by render_POST when the client is signalling
+ that it is about to be closed.
+
+ Args:
+ request (Request): Incoming request.
+
+ """
+ csessid=self.get_client_sessid(request)
+ try:
+ sess=self.sessionhandler.sessions_from_csessid(csessid)[0]
+ sess.sessionhandler.disconnect(sess)
+ exceptIndexError:
+ self.client_disconnect(csessid)
+ returnb'""'
+
+
[docs]defrender_POST(self,request):
+ """
+ This function is what Twisted calls with POST requests coming
+ in from the ajax client. The requests should be tagged with
+ different modes depending on what needs to be done, such as
+ initializing or sending/receving data through the request. It
+ uses a long-polling mechanism to avoid sending data unless
+ there is actual data available.
+
+ Args:
+ request (Request): Incoming request.
+
+ """
+ dmode=request.args.get(b"mode",[b"None"])[0].decode("utf-8")
+
+ ifdmode=="init":
+ # startup. Setup the server.
+ returnself.mode_init(request)
+ elifdmode=="input":
+ # input from the client to the server
+ returnself.mode_input(request)
+ elifdmode=="receive":
+ # the client is waiting to receive data.
+ returnself.mode_receive(request)
+ elifdmode=="close":
+ # the client is closing
+ returnself.mode_close(request)
+ elifdmode=="keepalive":
+ # A reply to our keepalive request - all is well
+ returnself.mode_keepalive(request)
+ else:
+ # This should not happen if client sends valid data.
+ returnb'""'
+
+
+#
+# A session type handling communication over the
+# web client interface.
+#
+
+
+
[docs]classAjaxWebClientSession(session.Session):
+ """
+ This represents a session running in an AjaxWebclient.
+ """
+
+
[docs]defget_client_session(self):
+ """
+ Get the Client browser session (used for auto-login based on browser session)
+
+ Returns:
+ csession (ClientSession): This is a django-specific internal representation
+ of the browser session.
+
+ """
+ ifself.csessid:
+ return_CLIENT_SESSIONS(session_key=self.csessid)
[docs]defsend_default(self,cmdname,*args,**kwargs):
+ """
+ Data Evennia -> User.
+
+ Args:
+ cmdname (str): The first argument will always be the oob cmd name.
+ *args (any): Remaining args will be arguments for `cmd`.
+
+ Keyword Args:
+ options (dict): These are ignored for oob commands. Use command
+ arguments (which can hold dicts) to send instructions to the
+ client instead.
+
+ """
+ ifnotcmdname=="options":
+ self.client.lineSend(self.csessid,[cmdname,args,kwargs])
Source code for evennia.server.profiling.dummyrunner
+"""
+Dummy client runner
+
+This module implements a stand-alone launcher for stress-testing
+an Evennia game. It will launch any number of fake clients. These
+clients will log into the server and start doing random operations.
+Customizing and weighing these operations differently depends on
+which type of game is tested. The module contains a testing module
+for plain Evennia.
+
+Please note that you shouldn't run this on a production server!
+Launch the program without any arguments or options to see a
+full step-by-step setup help.
+
+Basically (for testing default Evennia):
+
+ - Use an empty/testing database.
+ - set PERMISSION_ACCOUNT_DEFAULT = "Builder"
+ - start server, eventually with profiling active
+ - launch this client runner
+
+If you want to customize the runner's client actions
+(because you changed the cmdset or needs to better
+match your use cases or add more actions), you can
+change which actions by adding a path to
+
+ DUMMYRUNNER_ACTIONS_MODULE = <path.to.your.module>
+
+in your settings. See utils.dummyrunner_actions.py
+for instructions on how to define this module.
+
+"""
+
+
+importsys
+importtime
+importrandom
+fromargparseimportArgumentParser
+fromtwisted.conchimporttelnet
+fromtwisted.internetimportreactor,protocol
+fromtwisted.internet.taskimportLoopingCall
+
+fromdjango.confimportsettings
+fromevennia.utilsimportmod_import,time_format
+
+# Load the dummyrunner settings module
+
+DUMMYRUNNER_SETTINGS=mod_import(settings.DUMMYRUNNER_SETTINGS_MODULE)
+ifnotDUMMYRUNNER_SETTINGS:
+ raiseIOError(
+ "Error: Dummyrunner could not find settings file at %s"
+ %settings.DUMMYRUNNER_SETTINGS_MODULE
+ )
+
+DATESTRING="%Y%m%d%H%M%S"
+
+# Settings
+
+# number of clients to launch if no input is given on command line
+NCLIENTS=1
+# time between each 'tick', in seconds, if not set on command
+# line. All launched clients will be called upon to possibly do an
+# action with this frequency.
+TIMESTEP=DUMMYRUNNER_SETTINGS.TIMESTEP
+# chance of a client performing an action, per timestep. This helps to
+# spread out usage randomly, like it would be in reality.
+CHANCE_OF_ACTION=DUMMYRUNNER_SETTINGS.CHANCE_OF_ACTION
+# spread out the login action separately, having many accounts create accounts
+# and connect simultaneously is generally unlikely.
+CHANCE_OF_LOGIN=DUMMYRUNNER_SETTINGS.CHANCE_OF_LOGIN
+# Port to use, if not specified on command line
+TELNET_PORT=DUMMYRUNNER_SETTINGS.TELNET_PORTorsettings.TELNET_PORTS[0]
+#
+NLOGGED_IN=0
+
+
+# Messages
+
+
+INFO_STARTING="""
+ Dummyrunner starting using {N} dummy account(s). If you don't see
+ any connection messages, make sure that the Evennia server is
+ running.
+
+ Use Ctrl-C to stop/disconnect clients.
+ """
+
+ERROR_NO_MIXIN="""
+ Error: Evennia is not set up for dummyrunner. Before starting the
+ server, make sure to include the following at *the end* of your
+ settings file (remove when not using dummyrunner!):
+
+ from evennia.server.profiling.settings_mixin import *
+
+ This will change the settings in the following way:
+ - change PERMISSION_ACCOUNT_DEFAULT to 'Developer' to allow clients
+ to test all commands
+ - change PASSWORD_HASHERS to use a faster (but less safe) algorithm
+ when creating large numbers of accounts at the same time
+
+ If you don't want to use the custom settings of the mixin for some
+ reason, you can change their values manually after the import, or
+ add DUMMYRUNNER_MIXIN=True to your settings file to avoid this
+ error completely.
+
+ Warning: Don't run dummyrunner on a production database! It will
+ create a lot of spammy objects and accounts!
+ """
+
+
+ERROR_FEW_ACTIONS="""
+ Dummyrunner settings error: The ACTIONS tuple is too short: it must
+ contain at least login- and logout functions.
+ """
+
+
+HELPTEXT="""
+DO NOT RUN THIS ON A PRODUCTION SERVER! USE A CLEAN/TESTING DATABASE!
+
+This stand-alone program launches dummy telnet clients against a
+running Evennia server. The idea is to mimic real accounts logging in
+and repeatedly doing resource-heavy commands so as to stress test the
+game. It uses the default command set to log in and issue commands, so
+if that was customized, some of the functionality will not be tested
+(it will not fail, the commands will just not be recognized). The
+running clients will create new objects and rooms all over the place
+as part of their running, so using a clean/testing database is
+strongly recommended.
+
+Setup:
+ 1) setup a fresh/clean database (if using sqlite, just safe-copy
+ away your real evennia.db3 file and create a new one with
+ `evennia migrate`)
+ 2) in server/conf/settings.py, add
+
+ PERMISSION_ACCOUNT_DEFAULT="Builder"
+
+ This is so that the dummy accounts can test building operations.
+ You can also customize the dummyrunner by modifying a setting
+ file specified by DUMMYRUNNER_SETTINGS_MODULE
+
+ 3) Start Evennia like normal, optionally with profiling (--profile)
+ 4) Run this dummy runner via the evennia launcher:
+
+ evennia --dummyrunner <nr_of_clients>
+
+ 5) Log on and determine if game remains responsive despite the
+ heavier load. Note that if you activated profiling, there is a
+ considerate additional overhead from the profiler too so you
+ should usually not consider game responsivity when using the
+ profiler at the same time.
+ 6) If you use profiling, let the game run long enough to gather
+ data, then stop the server cleanly using evennia stop or @shutdown.
+ @shutdown. The profile appears as
+ server/logs/server.prof/portal.prof (see Python's manual on
+ cProfiler).
+
+Notes:
+
+The dummyrunner tends to create a lot of accounts all at once, which is
+a very heavy operation. This is not a realistic use-case - what you want
+to test is performance during run. A large
+number of clients here may lock up the client until all have been
+created. It may be better to connect multiple dummyrunners instead of
+starting one single one with a lot of accounts. Exactly what this number
+is depends on your computer power. So start with 10-20 clients and increase
+until you see the initial login slows things too much.
+
+"""
+
+# ------------------------------------------------------------
+# Helper functions
+# ------------------------------------------------------------
+
+
+ICOUNT=0
+
+
+
[docs]classDummyClient(telnet.StatefulTelnetProtocol):
+ """
+ Handles connection to a running Evennia server,
+ mimicking a real account by sending commands on
+ a timer.
+
+ """
+
+
[docs]defconnectionMade(self):
+ """
+ Called when connection is first established.
+
+ """
+ # public properties
+ self.cid=idcounter()
+ self.key="Dummy-%s"%self.cid
+ self.gid="%s-%s"%(time.strftime(DATESTRING),self.cid)
+ self.istep=0
+ self.exits=[]# exit names created
+ self.objs=[]# obj names created
+
+ self._connected=False
+ self._loggedin=False
+ self._logging_out=False
+ self._report=""
+ self._cmdlist=[]# already stepping in a cmd definition
+ self._login=self.factory.actions[0]
+ self._logout=self.factory.actions[1]
+ self._actions=self.factory.actions[2:]
+
+ reactor.addSystemEventTrigger("before","shutdown",self.logout)
+
+
[docs]defdataReceived(self,data):
+ """
+ Called when data comes in over the protocol. We wait to start
+ stepping until the server actually responds
+
+ Args:
+ data (str): Incoming data.
+
+ """
+ ifnotself._connectedandnotdata.startswith(chr(255)):
+ # wait until we actually get text back (not just telnet
+ # negotiation)
+ self._connected=True
+ # start client tick
+ d=LoopingCall(self.step)
+ # dissipate exact step by up to +/- 0.5 second
+ timestep=TIMESTEP+(-0.5+(random.random()*1.0))
+ d.start(timestep,now=True).addErrback(self.error)
+
+
[docs]defconnectionLost(self,reason):
+ """
+ Called when loosing the connection.
+
+ Args:
+ reason (str): Reason for loosing connection.
+
+ """
+ ifnotself._logging_out:
+ print("client %s(%s) lost connection (%s)"%(self.key,self.cid,reason))
[docs]defcounter(self):
+ """
+ Produces a unique id, also between clients.
+
+ Returns:
+ counter (int): A unique counter.
+
+ """
+ returngidcounter()
+
+
[docs]deflogout(self):
+ """
+ Causes the client to log out of the server. Triggered by ctrl-c signal.
+
+ """
+ self._logging_out=True
+ cmd=self._logout(self)
+ print("client %s(%s) logout (%s actions)"%(self.key,self.cid,self.istep))
+ self.sendLine(cmd)
+
+
[docs]defstep(self):
+ """
+ Perform a step. This is called repeatedly by the runner and
+ causes the client to issue commands to the server. This holds
+ all "intelligence" of the dummy client.
+
+ """
+ globalNLOGGED_IN
+
+ rand=random.random()
+
+ ifnotself._cmdlist:
+ # no commands ready. Load some.
+
+ ifnotself._loggedin:
+ ifrand<CHANCE_OF_LOGIN:
+ # get the login commands
+ self._cmdlist=list(makeiter(self._login(self)))
+ NLOGGED_IN+=1# this is for book-keeping
+ print("connecting client %s (%i/%i)..."%(self.key,NLOGGED_IN,NCLIENTS))
+ self._loggedin=True
+ else:
+ # no login yet, so cmdlist not yet set
+ return
+ else:
+ # we always pick a cumulatively random function
+ crand=random.random()
+ cfunc=[funcfor(cprob,func)inself._actionsifcprob>=crand][0]
+ self._cmdlist=list(makeiter(cfunc(self)))
+
+ # at this point we always have a list of commands
+ ifrand<CHANCE_OF_ACTION:
+ # send to the game
+ self.sendLine(str(self._cmdlist.pop(0)))
+ self.istep+=1
Source code for evennia.server.profiling.dummyrunner_settings
+"""
+Settings and actions for the dummyrunner
+
+This module defines dummyrunner settings and sets up
+the actions available to dummy accounts.
+
+The settings are global variables:
+
+TIMESTEP - time in seconds between each 'tick'
+CHANCE_OF_ACTION - chance 0-1 of action happening
+CHANCE_OF_LOGIN - chance 0-1 of login happening
+TELNET_PORT - port to use, defaults to settings.TELNET_PORT
+ACTIONS - see below
+
+ACTIONS is a tuple
+
+```
+(login_func, logout_func, (0.3, func1), (0.1, func2) ... )
+```
+
+where the first entry is the function to call on first connect, with a
+chance of occurring given by CHANCE_OF_LOGIN. This function is usually
+responsible for logging in the account. The second entry is always
+called when the dummyrunner disconnects from the server and should
+thus issue a logout command. The other entries are tuples (chance,
+func). They are picked randomly, their commonality based on the
+cumulative chance given (the chance is normalized between all options
+so if will still work also if the given chances don't add up to 1).
+Since each function can return a list of game-command strings, each
+function may result in multiple operations.
+
+An action-function is called with a "client" argument which is a
+reference to the dummy client currently performing the action. It
+returns a string or a list of command strings to execute. Use the
+client object for optionally saving data between actions.
+
+The client object has the following relevant properties and methods:
+
+- key - an optional client key. This is only used for dummyrunner output.
+ Default is "Dummy-<cid>"
+- cid - client id
+- gid - globally unique id, hashed with time stamp
+- istep - the current step
+- exits - an empty list. Can be used to store exit names
+- objs - an empty list. Can be used to store object names
+- counter() - returns a unique increasing id, hashed with time stamp
+ to make it unique also between dummyrunner instances.
+
+The return should either be a single command string or a tuple of
+command strings. This list of commands will always be executed every
+TIMESTEP with a chance given by CHANCE_OF_ACTION by in the order given
+(no randomness) and allows for setting up a more complex chain of
+commands (such as creating an account and logging in).
+
+---
+
+"""
+# Dummy runner settings
+
+# Time between each dummyrunner "tick", in seconds. Each dummy
+# will be called with this frequency.
+TIMESTEP=2
+
+# Chance of a dummy actually performing an action on a given tick.
+# This spreads out usage randomly, like it would be in reality.
+CHANCE_OF_ACTION=0.5
+
+# Chance of a currently unlogged-in dummy performing its login
+# action every tick. This emulates not all accounts logging in
+# at exactly the same time.
+CHANCE_OF_LOGIN=1.0
+
+# Which telnet port to connect to. If set to None, uses the first
+# default telnet port of the running server.
+TELNET_PORT=None
+
+
+# Setup actions tuple
+
+# some convenient templates
+
+DUMMY_NAME="Dummy-%s"
+DUMMY_PWD="password-%s"
+START_ROOM="testing_room_start_%s"
+ROOM_TEMPLATE="testing_room_%s"
+EXIT_TEMPLATE="exit_%s"
+OBJ_TEMPLATE="testing_obj_%s"
+TOBJ_TEMPLATE="testing_button_%s"
+TOBJ_TYPECLASS="contrib.tutorial_examples.red_button.RedButton"
+
+
+# action function definitions (pick and choose from
+# these to build a client "usage profile"
+
+# login/logout
+
+
+
[docs]defc_login(client):
+ "logins to the game"
+ # we always use a new client name
+ cname=DUMMY_NAME%client.gid
+ cpwd=DUMMY_PWD%client.gid
+
+ # set up for digging a first room (to move to and keep the
+ # login room clean)
+ roomname=ROOM_TEMPLATE%client.counter()
+ exitname1=EXIT_TEMPLATE%client.counter()
+ exitname2=EXIT_TEMPLATE%client.counter()
+ client.exits.extend([exitname1,exitname2])
+
+ cmds=(
+ "create %s%s"%(cname,cpwd),
+ "connect %s%s"%(cname,cpwd),
+ "@dig %s"%START_ROOM%client.gid,
+ "@teleport %s"%START_ROOM%client.gid,
+ "@dig %s = %s, %s"%(roomname,exitname1,exitname2),
+ )
+ returncmds
+
+
+
[docs]defc_login_nodig(client):
+ "logins, don't dig its own room"
+ cname=DUMMY_NAME%client.gid
+ cpwd=DUMMY_PWD%client.gid
+
+ cmds=("create %s%s"%(cname,cpwd),"connect %s%s"%(cname,cpwd))
+ returncmds
+
+
+
[docs]defc_logout(client):
+ "logouts of the game"
+ return"@quit"
+
+
+# random commands
+
+
+
[docs]defc_looks(client):
+ "looks at various objects"
+ cmds=["look %s"%objforobjinclient.objs]
+ ifnotcmds:
+ cmds=["look %s"%exiforexiinclient.exits]
+ ifnotcmds:
+ cmds="look"
+ returncmds
+
+
+
[docs]defc_examines(client):
+ "examines various objects"
+ cmds=["examine %s"%objforobjinclient.objs]
+ ifnotcmds:
+ cmds=["examine %s"%exiforexiinclient.exits]
+ ifnotcmds:
+ cmds="examine me"
+ returncmds
[docs]defc_digs(client):
+ "digs a new room, storing exit names on client"
+ roomname=ROOM_TEMPLATE%client.counter()
+ exitname1=EXIT_TEMPLATE%client.counter()
+ exitname2=EXIT_TEMPLATE%client.counter()
+ client.exits.extend([exitname1,exitname2])
+ return"@dig/tel %s = %s, %s"%(roomname,exitname1,exitname2)
+
+
+
[docs]defc_creates_obj(client):
+ "creates normal objects, storing their name on client"
+ objname=OBJ_TEMPLATE%client.counter()
+ client.objs.append(objname)
+ cmds=(
+ "@create %s"%objname,
+ '@desc %s = "this is a test object'%objname,
+ "@set %s/testattr = this is a test attribute value."%objname,
+ "@set %s/testattr2 = this is a second test attribute."%objname,
+ )
+ returncmds
+
+
+
[docs]defc_creates_button(client):
+ "creates example button, storing name on client"
+ objname=TOBJ_TEMPLATE%client.counter()
+ client.objs.append(objname)
+ cmds=("@create %s:%s"%(objname,TOBJ_TYPECLASS),"@desc %s = test red button!"%objname)
+ returncmds
[docs]defc_moves(client):
+ "moves to a previously created room, using the stored exits"
+ cmds=client.exits# try all exits - finally one will work
+ return"look"ifnotcmdselsecmds
+
+
+
[docs]defc_moves_n(client):
+ "move through north exit if available"
+ return"north"
+
+
+
[docs]defc_moves_s(client):
+ "move through south exit if available"
+ return"south"
+
+
+# Action tuple (required)
+#
+# This is a tuple of client action functions. The first element is the
+# function the client should use to log into the game and move to
+# STARTROOM . The second element is the logout command, for cleanly
+# exiting the mud. The following elements are 2-tuples of (probability,
+# action_function). The probablities should normally sum up to 1,
+# otherwise the system will normalize them.
+#
+
+
+# "normal builder" definitionj
+# ACTIONS = ( c_login,
+# c_logout,
+# (0.5, c_looks),
+# (0.08, c_examines),
+# (0.1, c_help),
+# (0.01, c_digs),
+# (0.01, c_creates_obj),
+# (0.3, c_moves))
+# "heavy" builder definition
+# ACTIONS = ( c_login,
+# c_logout,
+# (0.2, c_looks),
+# (0.1, c_examines),
+# (0.2, c_help),
+# (0.1, c_digs),
+# (0.1, c_creates_obj),
+# #(0.01, c_creates_button),
+# (0.2, c_moves))
+# "passive account" definition
+# ACTIONS = ( c_login,
+# c_logout,
+# (0.7, c_looks),
+# #(0.1, c_examines),
+# (0.3, c_help))
+# #(0.1, c_digs),
+# #(0.1, c_creates_obj),
+# #(0.1, c_creates_button),
+# #(0.4, c_moves))
+# "inactive account" definition
+# ACTIONS = (c_login_nodig,
+# c_logout,
+# (1.0, c_idles))
+# "normal account" definition
+ACTIONS=(c_login,c_logout,(0.01,c_digs),(0.39,c_looks),(0.2,c_help),(0.4,c_moves))
+# walking tester. This requires a pre-made
+# "loop" of multiple rooms that ties back
+# to limbo (using @tunnel and @open)
+# ACTIONS = (c_login_nodig,
+# c_logout,
+# (1.0, c_moves_n))
+# "socializing heavy builder" definition
+# ACTIONS = (c_login,
+# c_logout,
+# (0.1, c_socialize),
+# (0.1, c_looks),
+# (0.2, c_help),
+# (0.1, c_creates_obj),
+# (0.2, c_digs),
+# (0.3, c_moves))
+# "heavy digger memory tester" definition
+# ACTIONS = (c_login,
+# c_logout,
+# (1.0, c_digs))
+
+"""
+Script that saves memory and idmapper data over time.
+
+Data will be saved to game/logs/memoryusage.log. Note that
+the script will append to this file if it already exists.
+
+Call this module directly to plot the log (requires matplotlib and numpy).
+"""
+
+importos
+importsys
+importtime
+
+# TODO!
+# sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
+# os.environ['DJANGO_SETTINGS_MODULE'] = 'game.settings'
+importevennia
+fromevennia.utils.idmapperimportmodelsas_idmapper
+
+LOGFILE="logs/memoryusage.log"
+INTERVAL=30# log every 30 seconds
+
+
+
Source code for evennia.server.profiling.test_queries
+"""
+This is a little routine for viewing the sql queries that are executed by a given
+query as well as count them for optimization testing.
+
+"""
+
+importsys
+importos
+
+# sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
+# os.environ["DJANGO_SETTINGS_MODULE"] = "game.settings"
+fromdjango.dbimportconnection
+
+
+
[docs]defcount_queries(exec_string,setup_string):
+ """
+ Display queries done by exec_string. Use setup_string
+ to setup the environment to test.
+ """
+
+ exec(setup_string)
+
+ num_queries_old=len(connection.queries)
+ exec(exec_string)
+ nqueries=len(connection.queries)-num_queries_old
+
+ forqueryinconnection.queries[-nqueriesifnquerieselse1:]:
+ print(query["time"],query["sql"])
+ print("Number of queries: %s"%nqueries)
[docs]deftest_c_creates_obj(self):
+ objname="testing_obj_1"
+ self.assertEqual(
+ c_creates_obj(self.client),
+ (
+ "@create %s"%objname,
+ '@desc %s = "this is a test object'%objname,
+ "@set %s/testattr = this is a test attribute value."%objname,
+ "@set %s/testattr2 = this is a second test attribute."%objname,
+ ),
+ )
+ self.assertEqual(self.client.objs,[objname])
+ self.clear_client_lists()
Source code for evennia.server.profiling.timetrace
+"""
+Trace a message through the messaging system
+"""
+
+importtime
+
+
+
[docs]deftimetrace(message,idstring,tracemessage="TEST_MESSAGE",final=False):
+ """
+ Trace a message with time stamps.
+
+ Args:
+ message (str): The actual message coming through
+ idstring (str): An identifier string specifying where this trace is happening.
+ tracemessage (str): The start of the message to tag.
+ This message will get attached time stamp.
+ final (bool): This is the final leg in the path - include total time in message
+
+ """
+ ifmessage.startswith(tracemessage):
+ # the message is on the form TEST_MESSAGE tlast t0
+ # where t0 is the initial starting time and last is the time
+ # saved at the last stop.
+ try:
+ prefix,tlast,t0=message.split(None,2)
+ tlast,t0=float(tlast),float(t0)
+ except(IndexError,ValueError):
+ t0=time.time()
+ tlast=t0
+ t1=t0
+ else:
+ t1=time.time()
+ # print to log (important!)
+ print("** timetrace (%s): dT=%fs, total=%fs."%(idstring,t1-tlast,t1-t0))
+
+ iffinal:
+ message=" **** %s (total %f) **** "%(tracemessage,t1-t0)
+ else:
+ message="%s%f%f"%(tracemessage,t1,t0)
+ returnmessage
+"""
+This module implements the main Evennia server process, the core of
+the game engine.
+
+This module should be started with the 'twistd' executable since it
+sets up all the networking features. (this is done automatically
+by evennia/server/server_runner.py).
+
+"""
+importtime
+importsys
+importos
+
+fromtwisted.webimportstatic
+fromtwisted.applicationimportinternet,service
+fromtwisted.internetimportreactor,defer
+fromtwisted.internet.taskimportLoopingCall
+fromtwisted.python.logimportILogObserver
+
+importdjango
+
+django.setup()
+
+importevennia
+
+evennia._init()
+
+fromdjango.dbimportconnection
+fromdjango.db.utilsimportOperationalError
+fromdjango.confimportsettings
+
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.scripts.modelsimportScriptDB
+fromevennia.server.modelsimportServerConfig
+fromevennia.serverimportinitial_setup
+
+fromevennia.utils.utilsimportget_evennia_version,mod_import,make_iter
+fromevennia.utilsimportlogger
+fromevennia.commsimportchannelhandler
+fromevennia.server.sessionhandlerimportSESSIONS
+
+fromdjango.utils.translationimportgettextas_
+
+_SA=object.__setattr__
+
+# a file with a flag telling the server to restart after shutdown or not.
+SERVER_RESTART=os.path.join(settings.GAME_DIR,"server","server.restart")
+
+# module containing hook methods called during start_stop
+SERVER_STARTSTOP_MODULE=mod_import(settings.AT_SERVER_STARTSTOP_MODULE)
+
+# modules containing plugin services
+SERVER_SERVICES_PLUGIN_MODULES=make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)
+
+
+# ------------------------------------------------------------
+# Evennia Server settings
+# ------------------------------------------------------------
+
+SERVERNAME=settings.SERVERNAME
+VERSION=get_evennia_version()
+
+AMP_ENABLED=True
+AMP_HOST=settings.AMP_HOST
+AMP_PORT=settings.AMP_PORT
+AMP_INTERFACE=settings.AMP_INTERFACE
+
+WEBSERVER_PORTS=settings.WEBSERVER_PORTS
+WEBSERVER_INTERFACES=settings.WEBSERVER_INTERFACES
+
+GUEST_ENABLED=settings.GUEST_ENABLED
+
+# server-channel mappings
+WEBSERVER_ENABLED=settings.WEBSERVER_ENABLEDandWEBSERVER_PORTSandWEBSERVER_INTERFACES
+IRC_ENABLED=settings.IRC_ENABLED
+RSS_ENABLED=settings.RSS_ENABLED
+GRAPEVINE_ENABLED=settings.GRAPEVINE_ENABLED
+WEBCLIENT_ENABLED=settings.WEBCLIENT_ENABLED
+GAME_INDEX_ENABLED=settings.GAME_INDEX_ENABLED
+
+INFO_DICT={
+ "servername":SERVERNAME,
+ "version":VERSION,
+ "amp":"",
+ "errors":"",
+ "info":"",
+ "webserver":"",
+ "irc_rss":"",
+}
+
+try:
+ WEB_PLUGINS_MODULE=mod_import(settings.WEB_PLUGINS_MODULE)
+exceptImportError:
+ WEB_PLUGINS_MODULE=None
+ INFO_DICT["errors"]=(
+ "WARNING: settings.WEB_PLUGINS_MODULE not found - "
+ "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf."
+ )
+
+
+# Maintenance function - this is called repeatedly by the server
+
+_MAINTENANCE_COUNT=0
+_FLUSH_CACHE=None
+_IDMAPPER_CACHE_MAXSIZE=settings.IDMAPPER_CACHE_MAXSIZE
+_GAMETIME_MODULE=None
+
+_IDLE_TIMEOUT=settings.IDLE_TIMEOUT
+_LAST_SERVER_TIME_SNAPSHOT=0
+
+
+def_server_maintenance():
+ """
+ This maintenance function handles repeated checks and updates that
+ the server needs to do. It is called every minute.
+ """
+ globalEVENNIA,_MAINTENANCE_COUNT,_FLUSH_CACHE,_GAMETIME_MODULE
+ global_LAST_SERVER_TIME_SNAPSHOT
+
+ ifnot_FLUSH_CACHE:
+ fromevennia.utils.idmapper.modelsimportconditional_flushas_FLUSH_CACHE
+ ifnot_GAMETIME_MODULE:
+ fromevennia.utilsimportgametimeas_GAMETIME_MODULE
+
+ _MAINTENANCE_COUNT+=1
+
+ now=time.time()
+ if_MAINTENANCE_COUNT==1:
+ # first call after a reload
+ _GAMETIME_MODULE.SERVER_START_TIME=now
+ _GAMETIME_MODULE.SERVER_RUNTIME=ServerConfig.objects.conf("runtime",default=0.0)
+ _LAST_SERVER_TIME_SNAPSHOT=now
+ else:
+ # adjust the runtime not with 60s but with the actual elapsed time
+ # in case this may varies slightly from 60s.
+ _GAMETIME_MODULE.SERVER_RUNTIME+=now-_LAST_SERVER_TIME_SNAPSHOT
+ _LAST_SERVER_TIME_SNAPSHOT=now
+
+ # update game time and save it across reloads
+ _GAMETIME_MODULE.SERVER_RUNTIME_LAST_UPDATED=now
+ ServerConfig.objects.conf("runtime",_GAMETIME_MODULE.SERVER_RUNTIME)
+
+ if_MAINTENANCE_COUNT%5==0:
+ # check cache size every 5 minutes
+ _FLUSH_CACHE(_IDMAPPER_CACHE_MAXSIZE)
+ if_MAINTENANCE_COUNT%60==0:
+ # validate scripts every hour
+ evennia.ScriptDB.objects.validate()
+ if_MAINTENANCE_COUNT%61==0:
+ # validate channels off-sync with scripts
+ evennia.CHANNEL_HANDLER.update()
+ if_MAINTENANCE_COUNT%(60*7)==0:
+ # drop database connection every 7 hrs to avoid default timeouts on MySQL
+ # (see https://github.com/evennia/evennia/issues/1376)
+ connection.close()
+
+ # handle idle timeouts
+ if_IDLE_TIMEOUT>0:
+ reason=_("idle timeout exceeded")
+ to_disconnect=[]
+ forsessionin(
+ sessforsessinSESSIONS.values()if(now-sess.cmd_last)>_IDLE_TIMEOUT
+ ):
+ ifnotsession.accountornotsession.account.access(
+ session.account,"noidletimeout",default=False
+ ):
+ to_disconnect.append(session)
+
+ forsessioninto_disconnect:
+ SESSIONS.disconnect(session,reason=reason)
+
+
+# ------------------------------------------------------------
+# Evennia Main Server object
+# ------------------------------------------------------------
+
+
+
[docs]classEvennia(object):
+
+ """
+ The main Evennia server handler. This object sets up the database and
+ tracks and interlinks all the twisted network services that make up
+ evennia.
+ """
+
+
[docs]def__init__(self,application):
+ """
+ Setup the server.
+
+ application - an instantiated Twisted application
+
+ """
+ sys.path.insert(1,".")
+
+ # create a store of services
+ self.services=service.MultiService()
+ self.services.setServiceParent(application)
+ self.amp_protocol=None# set by amp factory
+ self.sessions=SESSIONS
+ self.sessions.server=self
+ self.process_id=os.getpid()
+
+ # Database-specific startup optimizations.
+ self.sqlite3_prep()
+
+ self.start_time=time.time()
+
+ # initialize channelhandler
+ try:
+ channelhandler.CHANNELHANDLER.update()
+ exceptOperationalError:
+ print("channelhandler couldn't update - db not set up")
+
+ # wrap the SIGINT handler to make sure we empty the threadpool
+ # even when we reload and we have long-running requests in queue.
+ # this is necessary over using Twisted's signal handler.
+ # (see https://github.com/evennia/evennia/issues/1128)
+ def_wrap_sigint_handler(*args):
+ fromtwisted.internet.deferimportDeferred
+
+ ifhasattr(self,"web_root"):
+ d=self.web_root.empty_threadpool()
+ d.addCallback(lambda_:self.shutdown("reload",_reactor_stopping=True))
+ else:
+ d=Deferred(lambda_:self.shutdown("reload",_reactor_stopping=True))
+ d.addCallback(lambda_:reactor.stop())
+ reactor.callLater(1,d.callback,None)
+
+ reactor.sigInt=_wrap_sigint_handler
+
+ # Server startup methods
+
+
[docs]defsqlite3_prep(self):
+ """
+ Optimize some SQLite stuff at startup since we
+ can't save it to the database.
+ """
+ if(
+ ".".join(str(i)foriindjango.VERSION)<"1.2"
+ andsettings.DATABASES.get("default",{}).get("ENGINE")=="sqlite3"
+ )or(
+ hasattr(settings,"DATABASES")
+ andsettings.DATABASES.get("default",{}).get("ENGINE",None)
+ =="django.db.backends.sqlite3"
+ ):
+ cursor=connection.cursor()
+ cursor.execute("PRAGMA cache_size=10000")
+ cursor.execute("PRAGMA synchronous=OFF")
+ cursor.execute("PRAGMA count_changes=OFF")
+ cursor.execute("PRAGMA temp_store=2")
+
+
[docs]defupdate_defaults(self):
+ """
+ We make sure to store the most important object defaults here, so
+ we can catch if they change and update them on-objects automatically.
+ This allows for changing default cmdset locations and default
+ typeclasses in the settings file and have them auto-update all
+ already existing objects.
+ """
+ globalINFO_DICT
+
+ # setting names
+ settings_names=(
+ "CMDSET_CHARACTER",
+ "CMDSET_ACCOUNT",
+ "BASE_ACCOUNT_TYPECLASS",
+ "BASE_OBJECT_TYPECLASS",
+ "BASE_CHARACTER_TYPECLASS",
+ "BASE_ROOM_TYPECLASS",
+ "BASE_EXIT_TYPECLASS",
+ "BASE_SCRIPT_TYPECLASS",
+ "BASE_CHANNEL_TYPECLASS",
+ )
+ # get previous and current settings so they can be compared
+ settings_compare=list(
+ zip(
+ [ServerConfig.objects.conf(name)fornameinsettings_names],
+ [settings.__getattr__(name)fornameinsettings_names],
+ )
+ )
+ mismatches=[
+ ifori,tupinenumerate(settings_compare)iftup[0]andtup[1]andtup[0]!=tup[1]
+ ]
+ iflen(
+ mismatches
+ ):# can't use any() since mismatches may be [0] which reads as False for any()
+ # we have a changed default. Import relevant objects and
+ # run the update
+ fromevennia.objects.modelsimportObjectDB
+ fromevennia.comms.modelsimportChannelDB
+
+ # from evennia.accounts.models import AccountDB
+ fori,prev,currin(
+ (i,tup[0],tup[1])fori,tupinenumerate(settings_compare)ifiinmismatches
+ ):
+ # update the database
+ INFO_DICT["info"]=(
+ " %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..."
+ %(settings_names[i],prev,curr)
+ )
+ ifi==0:
+ ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update(
+ db_cmdset_storage=curr
+ )
+ ifi==1:
+ AccountDB.objects.filter(db_cmdset_storage__exact=prev).update(
+ db_cmdset_storage=curr
+ )
+ ifi==2:
+ AccountDB.objects.filter(db_typeclass_path__exact=prev).update(
+ db_typeclass_path=curr
+ )
+ ifiin(3,4,5,6):
+ ObjectDB.objects.filter(db_typeclass_path__exact=prev).update(
+ db_typeclass_path=curr
+ )
+ ifi==7:
+ ScriptDB.objects.filter(db_typeclass_path__exact=prev).update(
+ db_typeclass_path=curr
+ )
+ ifi==8:
+ ChannelDB.objects.filter(db_typeclass_path__exact=prev).update(
+ db_typeclass_path=curr
+ )
+ # store the new default and clean caches
+ ServerConfig.objects.conf(settings_names[i],curr)
+ ObjectDB.flush_instance_cache()
+ AccountDB.flush_instance_cache()
+ ScriptDB.flush_instance_cache()
+ ChannelDB.flush_instance_cache()
+ # if this is the first start we might not have a "previous"
+ # setup saved. Store it now.
+ [
+ ServerConfig.objects.conf(settings_names[i],tup[1])
+ fori,tupinenumerate(settings_compare)
+ ifnottup[0]
+ ]
+
+
[docs]defrun_initial_setup(self):
+ """
+ This is triggered by the amp protocol when the connection
+ to the portal has been established.
+ This attempts to run the initial_setup script of the server.
+ It returns if this is not the first time the server starts.
+ Once finished the last_initial_setup_step is set to -1.
+ """
+ globalINFO_DICT
+ last_initial_setup_step=ServerConfig.objects.conf("last_initial_setup_step")
+ ifnotlast_initial_setup_step:
+ # None is only returned if the config does not exist,
+ # i.e. this is an empty DB that needs populating.
+ INFO_DICT["info"]=" Server started for the first time. Setting defaults."
+ initial_setup.handle_setup(0)
+ elifint(last_initial_setup_step)>=0:
+ # a positive value means the setup crashed on one of its
+ # modules and setup will resume from this step, retrying
+ # the last failed module. When all are finished, the step
+ # is set to -1 to show it does not need to be run again.
+ INFO_DICT["info"]=" Resuming initial setup from step {last}.".format(
+ last=last_initial_setup_step
+ )
+ initial_setup.handle_setup(int(last_initial_setup_step))
+
+
[docs]defrun_init_hooks(self,mode):
+ """
+ Called by the amp client once receiving sync back from Portal
+
+ Args:
+ mode (str): One of shutdown, reload or reset
+
+ """
+ fromevennia.objects.modelsimportObjectDB
+
+ # start server time and maintenance task
+ self.maintenance_task=LoopingCall(_server_maintenance)
+ self.maintenance_task.start(60,now=True)# call every minute
+
+ # update eventual changed defaults
+ self.update_defaults()
+
+ [o.at_init()foroinObjectDB.get_all_cached_instances()]
+ [p.at_init()forpinAccountDB.get_all_cached_instances()]
+
+ # call correct server hook based on start file value
+ ifmode=="reload":
+ logger.log_msg("Server successfully reloaded.")
+ self.at_server_reload_start()
+ elifmode=="reset":
+ # only run hook, don't purge sessions
+ self.at_server_cold_start()
+ logger.log_msg("Evennia Server successfully restarted in 'reset' mode.")
+ elifmode=="shutdown":
+ self.at_server_cold_start()
+ # clear eventual lingering session storages
+ ObjectDB.objects.clear_all_sessids()
+ logger.log_msg("Evennia Server successfully started.")
+
+ # always call this regardless of start type
+ self.at_server_start()
+
+
[docs]@defer.inlineCallbacks
+ defshutdown(self,mode="reload",_reactor_stopping=False):
+ """
+ Shuts down the server from inside it.
+
+ Keyword Args:
+ mode (str): Sets the server restart mode:
+ - 'reload': server restarts, no "persistent" scripts
+ are stopped, at_reload hooks called.
+ - 'reset' - server restarts, non-persistent scripts stopped,
+ at_shutdown hooks called but sessions will not
+ be disconnected.
+ -'shutdown' - like reset, but server will not auto-restart.
+ _reactor_stopping: This is set if server is stopped by a kill
+ command OR this method was already called
+ once - in both cases the reactor is dead/stopping already.
+
+ """
+ if_reactor_stoppingandhasattr(self,"shutdown_complete"):
+ # this means we have already passed through this method
+ # once; we don't need to run the shutdown procedure again.
+ defer.returnValue(None)
+
+ fromevennia.objects.modelsimportObjectDB
+ fromevennia.server.modelsimportServerConfig
+ fromevennia.utilsimportgametimeas_GAMETIME_MODULE
+
+ ifmode=="reload":
+ # call restart hooks
+ ServerConfig.objects.conf("server_restart_mode","reload")
+ yield[o.at_server_reload()foroinObjectDB.get_all_cached_instances()]
+ yield[p.at_server_reload()forpinAccountDB.get_all_cached_instances()]
+ yield[
+ (s.pause(manual_pause=False),s.at_server_reload())
+ forsinScriptDB.get_all_cached_instances()
+ ifs.idand(s.is_activeors.attributes.has("_manual_pause"))
+ ]
+ yieldself.sessions.all_sessions_portal_sync()
+ self.at_server_reload_stop()
+ # only save monitor state on reload, not on shutdown/reset
+ fromevennia.scripts.monitorhandlerimportMONITOR_HANDLER
+
+ MONITOR_HANDLER.save()
+ else:
+ ifmode=="reset":
+ # like shutdown but don't unset the is_connected flag and don't disconnect sessions
+ yield[o.at_server_shutdown()foroinObjectDB.get_all_cached_instances()]
+ yield[p.at_server_shutdown()forpinAccountDB.get_all_cached_instances()]
+ ifself.amp_protocol:
+ yieldself.sessions.all_sessions_portal_sync()
+ else:# shutdown
+ yield[_SA(p,"is_connected",False)forpinAccountDB.get_all_cached_instances()]
+ yield[o.at_server_shutdown()foroinObjectDB.get_all_cached_instances()]
+ yield[
+ (p.unpuppet_all(),p.at_server_shutdown())
+ forpinAccountDB.get_all_cached_instances()
+ ]
+ yieldObjectDB.objects.clear_all_sessids()
+ yield[
+ (
+ s.pause(manual_pause=s.attributes.get("_manual_pause",False)),
+ s.at_server_shutdown(),
+ )
+ forsinScriptDB.get_all_cached_instances()
+ ]
+ ServerConfig.objects.conf("server_restart_mode","reset")
+ self.at_server_cold_stop()
+
+ # tickerhandler state should always be saved.
+ fromevennia.scripts.tickerhandlerimportTICKER_HANDLER
+
+ TICKER_HANDLER.save()
+
+ # always called, also for a reload
+ self.at_server_stop()
+
+ ifhasattr(self,"web_root"):# not set very first start
+ yieldself.web_root.empty_threadpool()
+
+ ifnot_reactor_stopping:
+ # kill the server
+ self.shutdown_complete=True
+ reactor.callLater(1,reactor.stop)
+
+ # we make sure the proper gametime is saved as late as possible
+ ServerConfig.objects.conf("runtime",_GAMETIME_MODULE.runtime())
+
+
[docs]defget_info_dict(self):
+ "Return the server info, for display."
+ returnINFO_DICT
+
+ # server start/stop hooks
+
+
[docs]defat_server_start(self):
+ """
+ This is called every time the server starts up, regardless of
+ how it was shut down.
+ """
+ ifSERVER_STARTSTOP_MODULE:
+ SERVER_STARTSTOP_MODULE.at_server_start()
+
+
[docs]defat_server_stop(self):
+ """
+ This is called just before a server is shut down, regardless
+ of it is fore a reload, reset or shutdown.
+ """
+ ifSERVER_STARTSTOP_MODULE:
+ SERVER_STARTSTOP_MODULE.at_server_stop()
+
+
[docs]defat_server_reload_start(self):
+ """
+ This is called only when server starts back up after a reload.
+ """
+ ifSERVER_STARTSTOP_MODULE:
+ SERVER_STARTSTOP_MODULE.at_server_reload_start()
+
+
[docs]defat_post_portal_sync(self,mode):
+ """
+ This is called just after the portal has finished syncing back data to the server
+ after reconnecting.
+
+ Args:
+ mode (str): One of reload, reset or shutdown.
+
+ """
+
+ fromevennia.scripts.monitorhandlerimportMONITOR_HANDLER
+
+ MONITOR_HANDLER.restore(mode=="reload")
+
+ fromevennia.scripts.tickerhandlerimportTICKER_HANDLER
+
+ TICKER_HANDLER.restore(mode=="reload")
+
+ # after sync is complete we force-validate all scripts
+ # (this also starts any that didn't yet start)
+ ScriptDB.objects.validate(init_mode=mode)
+
+ # start the task handler
+ fromevennia.scripts.taskhandlerimportTASK_HANDLER
+
+ TASK_HANDLER.load()
+ TASK_HANDLER.create_delays()
+
+ # check so default channels exist
+ fromevennia.comms.modelsimportChannelDB
+ fromevennia.accounts.modelsimportAccountDB
+ fromevennia.utils.createimportcreate_channel
+
+ god_account=AccountDB.objects.get(id=1)
+ # mudinfo
+ mudinfo_chan=settings.CHANNEL_MUDINFO
+ ifnotmudinfo_chan:
+ raiseRuntimeError("settings.CHANNEL_MUDINFO must be defined.")
+ ifnotChannelDB.objects.filter(db_key=mudinfo_chan["key"]):
+ channel=create_channel(**mudinfo_chan)
+ channel.connect(god_account)
+ # connectinfo
+ connectinfo_chan=settings.CHANNEL_MUDINFO
+ ifconnectinfo_chan:
+ ifnotChannelDB.objects.filter(db_key=mudinfo_chan["key"]):
+ channel=create_channel(**connectinfo_chan)
+ # default channels
+ forchan_infoinsettings.DEFAULT_CHANNELS:
+ ifnotChannelDB.objects.filter(db_key=chan_info["key"]):
+ channel=create_channel(**chan_info)
+ channel.connect(god_account)
+
+ # delete the temporary setting
+ ServerConfig.objects.conf("server_restart_mode",delete=True)
+
+
[docs]defat_server_reload_stop(self):
+ """
+ This is called only time the server stops before a reload.
+ """
+ ifSERVER_STARTSTOP_MODULE:
+ SERVER_STARTSTOP_MODULE.at_server_reload_stop()
+
+
[docs]defat_server_cold_start(self):
+ """
+ This is called only when the server starts "cold", i.e. after a
+ shutdown or a reset.
+ """
+ # We need to do this just in case the server was killed in a way where
+ # the normal cleanup operations did not have time to run.
+ fromevennia.objects.modelsimportObjectDB
+
+ ObjectDB.objects.clear_all_sessids()
+
+ # Remove non-persistent scripts
+ fromevennia.scripts.modelsimportScriptDB
+
+ forscriptinScriptDB.objects.filter(db_persistent=False):
+ script.stop()
+
+ ifGUEST_ENABLED:
+ forguestinAccountDB.objects.all().filter(
+ db_typeclass_path=settings.BASE_GUEST_TYPECLASS
+ ):
+ forcharacteringuest.db._playable_characters:
+ ifcharacter:
+ character.delete()
+ guest.delete()
+ ifSERVER_STARTSTOP_MODULE:
+ SERVER_STARTSTOP_MODULE.at_server_cold_start()
+
+
[docs]defat_server_cold_stop(self):
+ """
+ This is called only when the server goes down due to a shutdown or reset.
+ """
+ ifSERVER_STARTSTOP_MODULE:
+ SERVER_STARTSTOP_MODULE.at_server_cold_stop()
+
+
+# ------------------------------------------------------------
+#
+# Start the Evennia game server and add all active services
+#
+# ------------------------------------------------------------
+
+
+# Tell the system the server is starting up; some things are not available yet
+try:
+ ServerConfig.objects.conf("server_starting_mode",True)
+exceptOperationalError:
+ print("Server server_starting_mode couldn't be set - database not set up.")
+
+
+# twistd requires us to define the variable 'application' so it knows
+# what to execute from.
+application=service.Application("Evennia")
+
+if"--nodaemon"notinsys.argv:
+ # custom logging, but only if we are not running in interactive mode
+ logfile=logger.WeeklyLogFile(
+ os.path.basename(settings.SERVER_LOG_FILE),
+ os.path.dirname(settings.SERVER_LOG_FILE),
+ day_rotation=settings.SERVER_LOG_DAY_ROTATION,
+ max_size=settings.SERVER_LOG_MAX_SIZE,
+ )
+ application.setComponent(ILogObserver,logger.ServerLogObserver(logfile).emit)
+
+# The main evennia server program. This sets up the database
+# and is where we store all the other services.
+EVENNIA=Evennia(application)
+
+ifAMP_ENABLED:
+
+ # The AMP protocol handles the communication between
+ # the portal and the mud server. Only reason to ever deactivate
+ # it would be during testing and debugging.
+
+ ifacestr=""
+ ifAMP_INTERFACE!="127.0.0.1":
+ ifacestr="-%s"%AMP_INTERFACE
+
+ INFO_DICT["amp"]="amp %s: %s"%(ifacestr,AMP_PORT)
+
+ fromevennia.serverimportamp_client
+
+ factory=amp_client.AMPClientFactory(EVENNIA)
+ amp_service=internet.TCPClient(AMP_HOST,AMP_PORT,factory)
+ amp_service.setName("ServerAMPClient")
+ EVENNIA.services.addService(amp_service)
+
+ifWEBSERVER_ENABLED:
+
+ # Start a django-compatible webserver.
+
+ fromevennia.server.webserverimport(
+ DjangoWebRoot,
+ WSGIWebServer,
+ Website,
+ LockableThreadPool,
+ PrivateStaticRoot,
+ )
+
+ # start a thread pool and define the root url (/) as a wsgi resource
+ # recognized by Django
+ threads=LockableThreadPool(
+ minthreads=max(1,settings.WEBSERVER_THREADPOOL_LIMITS[0]),
+ maxthreads=max(1,settings.WEBSERVER_THREADPOOL_LIMITS[1]),
+ )
+
+ web_root=DjangoWebRoot(threads)
+ # point our media resources to url /media
+ web_root.putChild(b"media",PrivateStaticRoot(settings.MEDIA_ROOT))
+ # point our static resources to url /static
+ web_root.putChild(b"static",PrivateStaticRoot(settings.STATIC_ROOT))
+ EVENNIA.web_root=web_root
+
+ ifWEB_PLUGINS_MODULE:
+ # custom overloads
+ web_root=WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root)
+
+ web_site=Website(web_root,logPath=settings.HTTP_LOG_FILE)
+ web_site.is_portal=False
+
+ INFO_DICT["webserver"]=""
+ forproxyport,serverportinWEBSERVER_PORTS:
+ # create the webserver (we only need the port for this)
+ webserver=WSGIWebServer(threads,serverport,web_site,interface="127.0.0.1")
+ webserver.setName("EvenniaWebServer%s"%serverport)
+ EVENNIA.services.addService(webserver)
+
+ INFO_DICT["webserver"]+="webserver: %s"%serverport
+
+ENABLED=[]
+ifIRC_ENABLED:
+ # IRC channel connections
+ ENABLED.append("irc")
+
+ifRSS_ENABLED:
+ # RSS feed channel connections
+ ENABLED.append("rss")
+
+ifGRAPEVINE_ENABLED:
+ # Grapevine channel connections
+ ENABLED.append("grapevine")
+
+ifGAME_INDEX_ENABLED:
+ fromevennia.server.game_index_client.serviceimportEvenniaGameIndexService
+
+ egi_service=EvenniaGameIndexService()
+ EVENNIA.services.addService(egi_service)
+
+ifENABLED:
+ INFO_DICT["irc_rss"]=", ".join(ENABLED)+" enabled."
+
+forplugin_moduleinSERVER_SERVICES_PLUGIN_MODULES:
+ # external plugin protocols - load here
+ plugin_module=mod_import(plugin_module)
+ ifplugin_module:
+ plugin_module.start_plugin_services(EVENNIA)
+ else:
+ print(f"Could not load plugin module {plugin_module}")
+
+# clear server startup mode
+try:
+ ServerConfig.objects.conf("server_starting_mode",delete=True)
+exceptOperationalError:
+ print("Server server_starting_mode couldn't unset - db not set up.")
+
+"""
+This defines a the Server's generic session object. This object represents
+a connection to the outside world but don't know any details about how the
+connection actually happens (so it's the same for telnet, web, ssh etc).
+
+It is stored on the Server side (as opposed to protocol-specific sessions which
+are stored on the Portal side)
+"""
+importweakref
+importtime
+fromdjango.utilsimporttimezone
+fromdjango.confimportsettings
+fromevennia.comms.modelsimportChannelDB
+fromevennia.utilsimportlogger
+fromevennia.utils.utilsimportmake_iter,lazy_property
+fromevennia.commands.cmdsethandlerimportCmdSetHandler
+fromevennia.server.sessionimportSession
+fromevennia.scripts.monitorhandlerimportMONITOR_HANDLER
+
+_GA=object.__getattribute__
+_SA=object.__setattr__
+_ObjectDB=None
+_ANSI=None
+
+# i18n
+fromdjango.utils.translationimportgettextas_
+
+# Handlers for Session.db/ndb operation
+
+
+
[docs]classNDbHolder(object):
+ """Holder for allowing property access of attributes"""
+
+
[docs]classNAttributeHandler(object):
+ """
+ NAttributeHandler version without recache protection.
+ This stand-alone handler manages non-database saving.
+ It is similar to `AttributeHandler` and is used
+ by the `.ndb` handler in the same way as `.db` does
+ for the `AttributeHandler`.
+ """
+
+
[docs]def__init__(self,obj):
+ """
+ Initialized on the object
+ """
+ self._store={}
+ self.obj=weakref.proxy(obj)
+
+
[docs]defhas(self,key):
+ """
+ Check if object has this attribute or not.
+
+ Args:
+ key (str): The Nattribute key to check.
+
+ Returns:
+ has_nattribute (bool): If Nattribute is set or not.
+
+ """
+ returnkeyinself._store
+
+
[docs]defget(self,key,default=None):
+ """
+ Get the named key value.
+
+ Args:
+ key (str): The Nattribute key to get.
+
+ Returns:
+ the value of the Nattribute.
+
+ """
+ returnself._store.get(key,default)
+
+
[docs]defadd(self,key,value):
+ """
+ Add new key and value.
+
+ Args:
+ key (str): The name of Nattribute to add.
+ value (any): The value to store.
+
+ """
+ self._store[key]=value
+
+
[docs]defremove(self,key):
+ """
+ Remove Nattribute from storage.
+
+ Args:
+ key (str): The name of the Nattribute to remove.
+
+ """
+ ifkeyinself._store:
+ delself._store[key]
+
+
[docs]defclear(self):
+ """
+ Remove all NAttributes from handler.
+
+ """
+ self._store={}
+
+
[docs]defall(self,return_tuples=False):
+ """
+ List the contents of the handler.
+
+ Args:
+ return_tuples (bool, optional): Defines if the Nattributes
+ are returns as a list of keys or as a list of `(key, value)`.
+
+ Returns:
+ nattributes (list): A list of keys `[key, key, ...]` or a
+ list of tuples `[(key, value), ...]` depending on the
+ setting of `return_tuples`.
+
+ """
+ ifreturn_tuples:
+ return[(key,value)for(key,value)inself._store.items()ifnotkey.startswith("_")]
+ return[keyforkeyinself._storeifnotkey.startswith("_")]
[docs]classServerSession(Session):
+ """
+ This class represents an account's session and is a template for
+ individual protocols to communicate with Evennia.
+
+ Each account gets a session assigned to them whenever they connect
+ to the game server. All communication between game and account goes
+ through their session.
+
+ """
+
+
[docs]def__init__(self):
+ """Initiate to avoid AttributeErrors down the line"""
+ self.puppet=None
+ self.account=None
+ self.cmdset_storage_string=""
+ self.cmdset=CmdSetHandler(self,True)
[docs]defat_sync(self):
+ """
+ This is called whenever a session has been resynced with the
+ portal. At this point all relevant attributes have already
+ been set and self.account been assigned (if applicable).
+
+ Since this is often called after a server restart we need to
+ set up the session as it was.
+
+ """
+ global_ObjectDB
+ ifnot_ObjectDB:
+ fromevennia.objects.modelsimportObjectDBas_ObjectDB
+
+ super(ServerSession,self).at_sync()
+ ifnotself.logged_in:
+ # assign the unloggedin-command set.
+ self.cmdset_storage=settings.CMDSET_UNLOGGEDIN
+
+ self.cmdset.update(init_mode=True)
+
+ ifself.puid:
+ # reconnect puppet (puid is only set if we are coming
+ # back from a server reload). This does all the steps
+ # done in the default @ic command but without any
+ # hooks, echoes or access checks.
+ obj=_ObjectDB.objects.get(id=self.puid)
+ obj.sessions.add(self)
+ obj.account=self.account
+ self.puid=obj.id
+ self.puppet=obj
+ # obj.scripts.validate()
+ obj.locks.cache_lock_bypass(obj)
+
+
[docs]defat_login(self,account):
+ """
+ Hook called by sessionhandler when the session becomes authenticated.
+
+ Args:
+ account (Account): The account associated with the session.
+
+ """
+ self.account=account
+ self.uid=self.account.id
+ self.uname=self.account.username
+ self.logged_in=True
+ self.conn_time=time.time()
+ self.puid=None
+ self.puppet=None
+ self.cmdset_storage=settings.CMDSET_SESSION
+
+ # Update account's last login time.
+ self.account.last_login=timezone.now()
+ self.account.save()
+
+ # add the session-level cmdset
+ self.cmdset=CmdSetHandler(self,True)
+
+
[docs]defat_disconnect(self,reason=None):
+ """
+ Hook called by sessionhandler when disconnecting this session.
+
+ """
+ ifself.logged_in:
+ account=self.account
+ ifself.puppet:
+ account.unpuppet_object(self)
+ uaccount=account
+ uaccount.last_login=timezone.now()
+ uaccount.save()
+ # calling account hook
+ account.at_disconnect(reason)
+ self.logged_in=False
+ ifnotself.sessionhandler.sessions_from_account(account):
+ # no more sessions connected to this account
+ account.is_connected=False
+ # this may be used to e.g. delete account after disconnection etc
+ account.at_post_disconnect()
+ # remove any webclient settings monitors associated with this
+ # session
+ MONITOR_HANDLER.remove(account,"_saved_webclient_options",self.sessid)
+
+
[docs]defget_account(self):
+ """
+ Get the account associated with this session
+
+ Returns:
+ account (Account): The associated Account.
+
+ """
+ returnself.logged_inandself.account
+
+
[docs]defget_puppet(self):
+ """
+ Get the in-game character associated with this session.
+
+ Returns:
+ puppet (Object): The puppeted object, if any.
+
+ """
+ returnself.logged_inandself.puppet
+
+ get_character=get_puppet
+
+
[docs]defget_puppet_or_account(self):
+ """
+ Get puppet or account.
+
+ Returns:
+ controller (Object or Account): The puppet if one exists,
+ otherwise return the account.
+
+ """
+ ifself.logged_in:
+ returnself.puppetifself.puppetelseself.account
+ returnNone
+
+
[docs]deflog(self,message,channel=True):
+ """
+ Emits session info to the appropriate outputs and info channels.
+
+ Args:
+ message (str): The message to log.
+ channel (bool, optional): Log to the CHANNEL_CONNECTINFO channel
+ in addition to the server log.
+
+ """
+ cchan=channelandsettings.CHANNEL_CONNECTINFO
+ ifcchan:
+ try:
+ cchan=ChannelDB.objects.get_channel(cchan["key"])
+ cchan.msg("[%s]: %s"%(cchan.key,message))
+ exceptException:
+ logger.log_trace()
+ logger.log_info(message)
+
+
[docs]defget_client_size(self):
+ """
+ Return eventual eventual width and height reported by the
+ client. Note that this currently only deals with a single
+ client window (windowID==0) as in a traditional telnet session.
+
+ """
+ flags=self.protocol_flags
+ width=flags.get("SCREENWIDTH",{}).get(0,settings.CLIENT_DEFAULT_WIDTH)
+ height=flags.get("SCREENHEIGHT",{}).get(0,settings.CLIENT_DEFAULT_HEIGHT)
+ returnwidth,height
+
+
[docs]defupdate_session_counters(self,idle=False):
+ """
+ Hit this when the user enters a command in order to update
+ idle timers and command counters.
+
+ """
+ # Idle time used for timeout calcs.
+ self.cmd_last=time.time()
+
+ # Store the timestamp of the user's last command.
+ ifnotidle:
+ # Increment the user's command counter.
+ self.cmd_total+=1
+ # Account-visible idle time, not used in idle timeout calcs.
+ self.cmd_last_visible=self.cmd_last
+
+
[docs]defupdate_flags(self,**kwargs):
+ """
+ Update the protocol_flags and sync them with Portal.
+
+ Keyword Args:
+ any: A key:value pair to set in the
+ protocol_flags dictionary.
+
+ Notes:
+ Since protocols can vary, no checking is done
+ as to the existene of the flag or not. The input
+ data should have been validated before this call.
+
+ """
+ ifkwargs:
+ self.protocol_flags.update(kwargs)
+ self.sessionhandler.session_portal_sync(self)
+
+
[docs]defdata_out(self,**kwargs):
+ """
+ Sending data from Evennia->Client
+
+ Keyword Args:
+ text (str or tuple)
+ any (str or tuple): Send-commands identified
+ by their keys. Or "options", carrying options
+ for the protocol(s).
+
+ """
+ self.sessionhandler.data_out(self,**kwargs)
+
+
[docs]defdata_in(self,**kwargs):
+ """
+ Receiving data from the client, sending it off to
+ the respective inputfuncs.
+
+ Keyword Args:
+ any: Incoming data from protocol on
+ the form `{"commandname": ((args), {kwargs}),...}`
+ Notes:
+ This method is here in order to give the user
+ a single place to catch and possibly process all incoming data from
+ the client. It should usually always end by sending
+ this data off to `self.sessionhandler.call_inputfuncs(self, **kwargs)`.
+
+ """
+ self.sessionhandler.call_inputfuncs(self,**kwargs)
+
+
[docs]defmsg(self,text=None,**kwargs):
+ """
+ Wrapper to mimic msg() functionality of Objects and Accounts.
+
+ Args:
+ text (str): String input.
+ kwargs (str or tuple): Send-commands identified
+ by their keys. Or "options", carrying options
+ for the protocol(s).
+
+ """
+ # this can happen if this is triggered e.g. a command.msg
+ # that auto-adds the session, we'd get a kwarg collision.
+ kwargs.pop("session",None)
+ kwargs.pop("from_obj",None)
+ iftextisnotNone:
+ self.data_out(text=text,**kwargs)
+ else:
+ self.data_out(**kwargs)
+
+
[docs]defexecute_cmd(self,raw_string,session=None,**kwargs):
+ """
+ Do something as this object. This method is normally never
+ called directly, instead incoming command instructions are
+ sent to the appropriate inputfunc already at the sessionhandler
+ level. This method allows Python code to inject commands into
+ this stream, and will lead to the text inputfunc be called.
+
+ Args:
+ raw_string (string): Raw command input
+ session (Session): This is here to make API consistent with
+ Account/Object.execute_cmd. If given, data is passed to
+ that Session, otherwise use self.
+ Keyword Args:
+ Other keyword arguments will be added to the found command
+ object instace as variables before it executes. This is
+ unused by default Evennia but may be used to set flags and
+ change operating paramaters for commands at run-time.
+
+ """
+ # inject instruction into input stream
+ kwargs["text"]=((raw_string,),{})
+ self.sessionhandler.data_in(sessionorself,**kwargs)
+
+ def__eq__(self,other):
+ """Handle session comparisons"""
+ try:
+ returnself.address==other.address
+ exceptAttributeError:
+ returnFalse
+
+ def__hash__(self):
+ """
+ Python 3 requires that any class which implements __eq__ must also
+ implement __hash__ and that the corresponding hashes for equivalent
+ instances are themselves equivalent.
+
+ """
+ returnhash(self.address)
+
+ def__ne__(self,other):
+ try:
+ returnself.address!=other.address
+ exceptAttributeError:
+ returnTrue
+
+ def__str__(self):
+ """
+ String representation of the user session class. We use
+ this a lot in the server logs.
+
+ """
+ symbol=""
+ ifself.logged_inandhasattr(self,"account")andself.account:
+ symbol="(#%s)"%self.account.id
+ try:
+ ifhasattr(self.address,"__iter__"):
+ address=":".join([str(part)forpartinself.address])
+ else:
+ address=self.address
+ exceptException:
+ address=self.address
+ return"%s%s@%s"%(self.uname,symbol,address)
+
+ def__repr__(self):
+ return"%s"%str(self)
+
+ # Dummy API hooks for use during non-loggedin operation
+
+
[docs]defat_cmdset_get(self,**kwargs):
+ """
+ A dummy hook all objects with cmdsets need to have
+ """
+
+ pass
+
+ # Mock db/ndb properties for allowing easy storage on the session
+ # (note that no databse is involved at all here. session.db.attr =
+ # value just saves a normal property in memory, just like ndb).
+
+
[docs]defndb_get(self):
+ """
+ A non-persistent store (ndb: NonDataBase). Everything stored
+ to this is guaranteed to be cleared when a server is shutdown.
+ Syntax is same as for the _get_db_holder() method and
+ property, e.g. obj.ndb.attr = value etc.
+
+ """
+ try:
+ returnself._ndb_holder
+ exceptAttributeError:
+ self._ndb_holder=NDbHolder(self,"nattrhandler",manager_name="nattributes")
+ returnself._ndb_holder
+
+ # @ndb.setter
+
[docs]defndb_set(self,value):
+ """
+ Stop accidentally replacing the db object
+
+ Args:
+ value (any): A value to store in the ndb.
+
+ """
+ string="Cannot assign directly to ndb object! "
+ string+="Use ndb.attr=value instead."
+ raiseException(string)
+
+ ndb=property(ndb_get,ndb_set,ndb_del)
+ db=property(ndb_get,ndb_set,ndb_del)
+
+ # Mock access method for the session (there is no lock info
+ # at this stage, so we just present a uniform API)
+
[docs]defaccess(self,*args,**kwargs):
+ """Dummy method to mimic the logged-in API."""
+ returnTrue
+"""
+This module defines a generic session class. All connection instances
+(both on Portal and Server side) should inherit from this class.
+
+"""
+fromdjango.confimportsettings
+
+importtime
+
+# ------------------------------------------------------------
+# Server Session
+# ------------------------------------------------------------
+
+
+
[docs]classSession(object):
+ """
+ This class represents a player's session and is a template for
+ both portal- and server-side sessions.
+
+ Each connection will see two session instances created:
+
+ 1. A Portal session. This is customized for the respective connection
+ protocols that Evennia supports, like Telnet, SSH etc. The Portal
+ session must call init_session() as part of its initialization. The
+ respective hook methods should be connected to the methods unique
+ for the respective protocol so that there is a unified interface
+ to Evennia.
+ 2. A Server session. This is the same for all connected accounts,
+ regardless of how they connect.
+
+ The Portal and Server have their own respective sessionhandlers. These
+ are synced whenever new connections happen or the Server restarts etc,
+ which means much of the same information must be stored in both places
+ e.g. the portal can re-sync with the server when the server reboots.
+
+ """
+
+ # names of attributes that should be affected by syncing.
+ _attrs_to_sync=(
+ "protocol_key",
+ "address",
+ "suid",
+ "sessid",
+ "uid",
+ "csessid",
+ "uname",
+ "logged_in",
+ "puid",
+ "conn_time",
+ "cmd_last",
+ "cmd_last_visible",
+ "cmd_total",
+ "protocol_flags",
+ "server_data",
+ "cmdset_storage_string",
+ )
+
+
[docs]definit_session(self,protocol_key,address,sessionhandler):
+ """
+ Initialize the Session. This should be called by the protocol when
+ a new session is established.
+
+ Args:
+ protocol_key (str): By default, one of 'telnet', 'telnet/ssl', 'ssh',
+ 'webclient/websocket' or 'webclient/ajax'.
+ address (str): Client address.
+ sessionhandler (SessionHandler): Reference to the
+ main sessionhandler instance.
+
+ """
+ # This is currently 'telnet', 'ssh', 'ssl' or 'web'
+ self.protocol_key=protocol_key
+ # Protocol address tied to this session
+ self.address=address
+
+ # suid is used by some protocols, it's a hex key.
+ self.suid=None
+
+ # unique id for this session
+ self.sessid=0# no sessid yet
+ # client session id, if given by the client
+ self.csessid=None
+ # database id for the user connected to this session
+ self.uid=None
+ # user name, for easier tracking of sessions
+ self.uname=None
+ # if user has authenticated already or not
+ self.logged_in=False
+
+ # database id of puppeted object (if any)
+ self.puid=None
+
+ # session time statistics
+ self.conn_time=time.time()
+ self.cmd_last_visible=self.conn_time
+ self.cmd_last=self.conn_time
+ self.cmd_total=0
+
+ self.protocol_flags={
+ "ENCODING":"utf-8",
+ "SCREENREADER":False,
+ "INPUTDEBUG":False,
+ "RAW":False,
+ "NOCOLOR":False,
+ }
+ self.server_data={}
+
+ # map of input data to session methods
+ self.datamap={}
+
+ # a back-reference to the relevant sessionhandler this
+ # session is stored in.
+ self.sessionhandler=sessionhandler
+
+
[docs]defget_sync_data(self):
+ """
+ Get all data relevant to sync the session.
+
+ Args:
+ syncdata (dict): All syncdata values, based on
+ the keys given by self._attrs_to_sync.
+
+ """
+ returndict(
+ (key,value)forkey,valueinself.__dict__.items()ifkeyinself._attrs_to_sync
+ )
+
+
[docs]defload_sync_data(self,sessdata):
+ """
+ Takes a session dictionary, as created by get_sync_data, and
+ loads it into the correct properties of the session.
+
+ Args:
+ sessdata (dict): Session data dictionary.
+
+ """
+ forpropname,valueinsessdata.items():
+ if(
+ propname=="protocol_flags"
+ andisinstance(value,dict)
+ andhasattr(self,"protocol_flags")
+ andisinstance(self.protocol_flags,dict)
+ ):
+ # special handling to allow partial update of protocol flags
+ self.protocol_flags.update(value)
+ else:
+ setattr(self,propname,value)
+
+
[docs]defat_sync(self):
+ """
+ Called after a session has been fully synced (including
+ secondary operations such as setting self.account based
+ on uid etc).
+
+ """
+ ifself.account:
+ self.protocol_flags.update(
+ self.account.attributes.get("_saved_protocol_flags",None)or{}
+ )
+
+ # access hooks
+
+
[docs]defdisconnect(self,reason=None):
+ """
+ generic hook called from the outside to disconnect this session
+ should be connected to the protocols actual disconnect mechanism.
+
+ Args:
+ reason (str): Eventual text motivating the disconnect.
+
+ """
+ pass
+
+
[docs]defdata_out(self,**kwargs):
+ """
+ Generic hook for sending data out through the protocol. Server
+ protocols can use this right away. Portal sessions
+ should overload this to format/handle the outgoing data as needed.
+
+ Keyword Args:
+ kwargs (any): Other data to the protocol.
+
+ """
+ pass
+
+
[docs]defdata_in(self,**kwargs):
+ """
+ Hook for protocols to send incoming data to the engine.
+
+ Keyword Args:
+ kwargs (any): Other data from the protocol.
+
+ """
+ pass
+"""
+This module defines handlers for storing sessions when handles
+sessions of users connecting to the server.
+
+There are two similar but separate stores of sessions:
+
+ - ServerSessionHandler - this stores generic game sessions
+ for the game. These sessions has no knowledge about
+ how they are connected to the world.
+ - PortalSessionHandler - this stores sessions created by
+ twisted protocols. These are dumb connectors that
+ handle network communication but holds no game info.
+
+"""
+importtime
+
+fromdjango.confimportsettings
+fromevennia.commands.cmdhandlerimportCMD_LOGINSTART
+fromevennia.utils.loggerimportlog_trace
+fromevennia.utils.utilsimport(
+ variable_from_module,
+ is_iter,
+ make_iter,
+ delay,
+ callables_from_module,
+)
+fromevennia.server.signalsimportSIGNAL_ACCOUNT_POST_LOGIN,SIGNAL_ACCOUNT_POST_LOGOUT
+fromevennia.server.signalsimportSIGNAL_ACCOUNT_POST_FIRST_LOGIN,SIGNAL_ACCOUNT_POST_LAST_LOGOUT
+fromevennia.utils.inlinefuncsimportparse_inlinefunc
+fromcodecsimportdecodeascodecs_decode
+
+_INLINEFUNC_ENABLED=settings.INLINEFUNC_ENABLED
+
+# delayed imports
+_AccountDB=None
+_ServerSession=None
+_ServerConfig=None
+_ScriptDB=None
+_OOB_HANDLER=None
+
+_ERR_BAD_UTF8="Your client sent an incorrect UTF-8 sequence."
+
+
+
[docs]classServerSessionHandler(SessionHandler):
+ """
+ This object holds the stack of sessions active in the game at
+ any time.
+
+ A session register with the handler in two steps, first by
+ registering itself with the connect() method. This indicates an
+ non-authenticated session. Whenever the session is authenticated
+ the session together with the related account is sent to the login()
+ method.
+
+ """
+
+ # AMP communication methods
+
+
[docs]def__init__(self,*args,**kwargs):
+ """
+ Init the handler.
+
+ """
+ super().__init__(*args,**kwargs)
+ self.server=None# set at server initialization
+ self.server_data={"servername":_SERVERNAME}
+ # will be set on psync
+ self.portal_start_time=0.0
+
+ def_run_cmd_login(self,session):
+ """
+ Launch the CMD_LOGINSTART command. This is wrapped
+ for delays.
+
+ """
+ ifnotsession.logged_in:
+ self.data_in(session,text=[[CMD_LOGINSTART],{}])
+
+
[docs]defportal_connect(self,portalsessiondata):
+ """
+ Called by Portal when a new session has connected.
+ Creates a new, unlogged-in game session.
+
+ Args:
+ portalsessiondata (dict): a dictionary of all property:value
+ keys defining the session and which is marked to be
+ synced.
+
+ """
+ delayed_import()
+ global_ServerSession,_AccountDB,_ScriptDB
+
+ sess=_ServerSession()
+ sess.sessionhandler=self
+ sess.load_sync_data(portalsessiondata)
+ sess.at_sync()
+ # validate all scripts
+ _ScriptDB.objects.validate()
+ self[sess.sessid]=sess
+
+ ifsess.logged_inandsess.uid:
+ # Session is already logged in. This can happen in the
+ # case of auto-authenticating protocols like SSH or
+ # webclient's session sharing
+ account=_AccountDB.objects.get_account_from_uid(sess.uid)
+ ifaccount:
+ # this will set account.is_connected too
+ self.login(sess,account,force=True)
+ return
+ else:
+ sess.logged_in=False
+ sess.uid=None
+
+ # show the first login command, may delay slightly to allow
+ # the handshakes to finish.
+ delay(_DELAY_CMD_LOGINSTART,self._run_cmd_login,sess)
+
+
[docs]defportal_session_sync(self,portalsessiondata):
+ """
+ Called by Portal when it wants to update a single session (e.g.
+ because of all negotiation protocols have finally replied)
+
+ Args:
+ portalsessiondata (dict): a dictionary of all property:value
+ keys defining the session and which is marked to be
+ synced.
+
+ """
+ sessid=portalsessiondata.get("sessid")
+ session=self.get(sessid)
+ ifsession:
+ # since some of the session properties may have had
+ # a chance to change already before the portal gets here
+ # the portal doesn't send all sessiondata but only
+ # ones which should only be changed from portal (like
+ # protocol_flags etc)
+ session.load_sync_data(portalsessiondata)
+
+
[docs]defportal_sessions_sync(self,portalsessionsdata):
+ """
+ Syncing all session ids of the portal with the ones of the
+ server. This is instantiated by the portal when reconnecting.
+
+ Args:
+ portalsessionsdata (dict): A dictionary
+ `{sessid: {property:value},...}` defining each session and
+ the properties in it which should be synced.
+
+ """
+ delayed_import()
+ global_ServerSession,_AccountDB,_ServerConfig,_ScriptDB
+
+ forsessinlist(self.values()):
+ # we delete the old session to make sure to catch eventual
+ # lingering references.
+ delsess
+
+ forsessid,sessdictinportalsessionsdata.items():
+ sess=_ServerSession()
+ sess.sessionhandler=self
+ sess.load_sync_data(sessdict)
+ ifsess.uid:
+ sess.account=_AccountDB.objects.get_account_from_uid(sess.uid)
+ self[sessid]=sess
+ sess.at_sync()
+
+ mode="reload"
+
+ # tell the server hook we synced
+ self.server.at_post_portal_sync(mode)
+ # announce the reconnection
+ self.announce_all(_(" ... Server restarted."))
+
+
[docs]defportal_disconnect(self,session):
+ """
+ Called from Portal when Portal session closed from the portal
+ side. There is no message to report in this case.
+
+ Args:
+ session (Session): The Session to disconnect
+
+ """
+ # disconnect us without calling Portal since
+ # Portal already knows.
+ self.disconnect(session,reason="",sync_portal=False)
+
+
[docs]defportal_disconnect_all(self):
+ """
+ Called from Portal when Portal is closing down. All
+ Sessions should die. The Portal should not be informed.
+
+ """
+ # set a watchdog to avoid self.disconnect from deleting
+ # the session while we are looping over them
+ self._disconnect_all=True
+ forsessioninself.values():
+ session.disconnect()
+ delself._disconnect_all
+
+ # server-side access methods
+
+
[docs]defstart_bot_session(self,protocol_path,configdict):
+ """
+ This method allows the server-side to force the Portal to
+ create a new bot session.
+
+ Args:
+ protocol_path (str): The full python path to the bot's
+ class.
+ configdict (dict): This dict will be used to configure
+ the bot (this depends on the bot protocol).
+
+ Examples:
+ start_bot_session("evennia.server.portal.irc.IRCClient",
+ {"uid":1, "botname":"evbot", "channel":"#evennia",
+ "network:"irc.freenode.net", "port": 6667})
+
+ Notes:
+ The new session will use the supplied account-bot uid to
+ initiate an already logged-in connection. The Portal will
+ treat this as a normal connection and henceforth so will
+ the Server.
+
+ """
+ self.server.amp_protocol.send_AdminServer2Portal(
+ DUMMYSESSION,operation=SCONN,protocol_path=protocol_path,config=configdict
+ )
+
+
[docs]defportal_restart_server(self):
+ """
+ Called by server when reloading. We tell the portal to start a new server instance.
+
+ """
+ self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION,operation=SRELOAD)
+
+
[docs]defportal_reset_server(self):
+ """
+ Called by server when reloading. We tell the portal to start a new server instance.
+
+ """
+ self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION,operation=SRESET)
+
+
[docs]defportal_shutdown(self):
+ """
+ Called by server when it's time to shut down (the portal will shut us down and then shut
+ itself down)
+
+ """
+ self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION,operation=PSHUTD)
+
+
[docs]deflogin(self,session,account,force=False,testmode=False):
+ """
+ Log in the previously unloggedin session and the account we by
+ now should know is connected to it. After this point we assume
+ the session to be logged in one way or another.
+
+ Args:
+ session (Session): The Session to authenticate.
+ account (Account): The Account identified as associated with this Session.
+ force (bool): Login also if the session thinks it's already logged in
+ (this can happen for auto-authenticating protocols)
+ testmode (bool, optional): This is used by unittesting for
+ faking login without any AMP being actually active.
+
+ """
+ ifsession.logged_inandnotforce:
+ # don't log in a session that is already logged in.
+ return
+
+ account.is_connected=True
+
+ # sets up and assigns all properties on the session
+ session.at_login(account)
+
+ # account init
+ account.at_init()
+
+ # Check if this is the first time the *account* logs in
+ ifaccount.db.FIRST_LOGIN:
+ account.at_first_login()
+ delaccount.db.FIRST_LOGIN
+
+ account.at_pre_login()
+
+ if_MULTISESSION_MODE==0:
+ # disconnect all previous sessions.
+ self.disconnect_duplicate_sessions(session)
+
+ nsess=len(self.sessions_from_account(account))
+ string="Logged in: {account}{address} ({nsessions} session(s) total)"
+ string=string.format(account=account,address=session.address,nsessions=nsess)
+ session.log(string)
+ session.logged_in=True
+ # sync the portal to the session
+ ifnottestmode:
+ self.server.amp_protocol.send_AdminServer2Portal(
+ session,operation=SLOGIN,sessiondata={"logged_in":True,"uid":session.uid}
+ )
+ account.at_post_login(session=session)
+ ifnsess<2:
+ SIGNAL_ACCOUNT_POST_FIRST_LOGIN.send(sender=account,session=session)
+ SIGNAL_ACCOUNT_POST_LOGIN.send(sender=account,session=session)
+
+
[docs]defdisconnect(self,session,reason="",sync_portal=True):
+ """
+ Called from server side to remove session and inform portal
+ of this fact.
+
+ Args:
+ session (Session): The Session to disconnect.
+ reason (str, optional): A motivation for the disconnect.
+ sync_portal (bool, optional): Sync the disconnect to
+ Portal side. This should be done unless this was
+ called by self.portal_disconnect().
+
+ """
+ session=self.get(session.sessid)
+ ifnotsession:
+ return
+
+ ifhasattr(session,"account")andsession.account:
+ # only log accounts logging off
+ nsess=len(self.sessions_from_account(session.account))-1
+ sreason=" ({})".format(reason)ifreasonelse""
+ string="Logged out: {account}{address} ({nsessions} sessions(s) remaining){reason}"
+ string=string.format(
+ reason=sreason,account=session.account,address=session.address,nsessions=nsess
+ )
+ session.log(string)
+
+ ifnsess==0:
+ SIGNAL_ACCOUNT_POST_LAST_LOGOUT.send(sender=session.account,session=session)
+
+ session.at_disconnect(reason)
+ SIGNAL_ACCOUNT_POST_LOGOUT.send(sender=session.account,session=session)
+ sessid=session.sessid
+ ifsessidinselfandnothasattr(self,"_disconnect_all"):
+ delself[sessid]
+ ifsync_portal:
+ # inform portal that session should be closed.
+ self.server.amp_protocol.send_AdminServer2Portal(
+ session,operation=SDISCONN,reason=reason
+ )
+
+
[docs]defall_sessions_portal_sync(self):
+ """
+ This is called by the server when it reboots. It syncs all session data
+ to the portal. Returns a deferred!
+
+ """
+ sessdata=self.get_all_sync_data()
+ returnself.server.amp_protocol.send_AdminServer2Portal(
+ DUMMYSESSION,operation=SSYNC,sessiondata=sessdata
+ )
+
+
[docs]defsession_portal_sync(self,session):
+ """
+ This is called by the server when it wants to sync a single session
+ with the Portal for whatever reason. Returns a deferred!
+
+ """
+ sessdata={session.sessid:session.get_sync_data()}
+ returnself.server.amp_protocol.send_AdminServer2Portal(
+ DUMMYSESSION,operation=SSYNC,sessiondata=sessdata,clean=False
+ )
+
+
[docs]defsession_portal_partial_sync(self,session_data):
+ """
+ Call to make a partial update of the session, such as only a particular property.
+
+ Args:
+ session_data (dict): Store `{sessid: {property:value}, ...}` defining one or
+ more sessions in detail.
+
+ """
+ returnself.server.amp_protocol.send_AdminServer2Portal(
+ DUMMYSESSION,operation=SSYNC,sessiondata=session_data,clean=False
+ )
+
+
[docs]defdisconnect_all_sessions(self,reason="You have been disconnected."):
+ """
+ Cleanly disconnect all of the connected sessions.
+
+ Args:
+ reason (str, optional): The reason for the disconnection.
+
+ """
+
+ forsessioninself:
+ delsession
+ # tell portal to disconnect all sessions
+ self.server.amp_protocol.send_AdminServer2Portal(
+ DUMMYSESSION,operation=SDISCONNALL,reason=reason
+ )
+
+
[docs]defdisconnect_duplicate_sessions(
+ self,curr_session,reason=_("Logged in from elsewhere. Disconnecting.")
+ ):
+ """
+ Disconnects any existing sessions with the same user.
+
+ args:
+ curr_session (Session): Disconnect all Sessions matching this one.
+ reason (str, optional): A motivation for disconnecting.
+
+ """
+ uid=curr_session.uid
+ # we can't compare sessions directly since this will compare addresses and
+ # mean connecting from the same host would not catch duplicates
+ sid=id(curr_session)
+ doublet_sessions=[
+ sessforsessinself.values()ifsess.logged_inandsess.uid==uidandid(sess)!=sid
+ ]
+
+ forsessionindoublet_sessions:
+ self.disconnect(session,reason)
+
+
[docs]defvalidate_sessions(self):
+ """
+ Check all currently connected sessions (logged in and not) and
+ see if any are dead or idle.
+
+ """
+ tcurr=time.time()
+ reason=_("Idle timeout exceeded, disconnecting.")
+ forsessionin(
+ session
+ forsessioninself.values()
+ ifsession.logged_in
+ and_IDLE_TIMEOUT>0
+ and(tcurr-session.cmd_last)>_IDLE_TIMEOUT
+ ):
+ self.disconnect(session,reason=reason)
+
+
[docs]defaccount_count(self):
+ """
+ Get the number of connected accounts (not sessions since a
+ account may have more than one session depending on settings).
+ Only logged-in accounts are counted here.
+
+ Returns:
+ naccount (int): Number of connected accounts
+
+ """
+ returnlen(set(session.uidforsessioninself.values()ifsession.logged_in))
+
+
[docs]defall_connected_accounts(self):
+ """
+ Get a unique list of connected and logged-in Accounts.
+
+ Returns:
+ accounts (list): All conected Accounts (which may be fewer than the
+ amount of Sessions due to multi-playing).
+
+ """
+ returnlist(
+ set(
+ session.account
+ forsessioninself.values()
+ ifsession.logged_inandsession.account
+ )
+ )
+
+
[docs]defsession_from_sessid(self,sessid):
+ """
+ Get session based on sessid, or None if not found
+
+ Args:
+ sessid (int or list): Session id(s).
+
+ Return:
+ sessions (Session or list): Session(s) found. This
+ is a list if input was a list.
+
+ """
+ ifis_iter(sessid):
+ return[self.get(sid)forsidinsessidifsidinself]
+ returnself.get(sessid)
+
+
[docs]defsession_from_account(self,account,sessid):
+ """
+ Given an account and a session id, return the actual session
+ object.
+
+ Args:
+ account (Account): The Account to get the Session from.
+ sessid (int or list): Session id(s).
+
+ Returns:
+ sessions (Session or list): Session(s) found.
+
+ """
+ sessions=[
+ self[sid]
+ forsidinmake_iter(sessid)
+ ifsidinselfandself[sid].logged_inandaccount.uid==self[sid].uid
+ ]
+ returnsessions[0]iflen(sessions)==1elsesessions
+
+
[docs]defsessions_from_account(self,account):
+ """
+ Given an account, return all matching sessions.
+
+ Args:
+ account (Account): Account to get sessions from.
+
+ Returns:
+ sessions (list): All Sessions associated with this account.
+
+ """
+ uid=account.uid
+ return[sessionforsessioninself.values()ifsession.logged_inandsession.uid==uid]
+
+
[docs]defsessions_from_puppet(self,puppet):
+ """
+ Given a puppeted object, return all controlling sessions.
+
+ Args:
+ puppet (Object): Object puppeted
+
+ Returns.
+ sessions (Session or list): Can be more than one of Object is controlled by
+ more than one Session (MULTISESSION_MODE > 1).
+
+ """
+ sessions=puppet.sessid.get()
+ returnsessions[0]iflen(sessions)==1elsesessions
[docs]defsessions_from_csessid(self,csessid):
+ """
+ Given a client identification hash (for session types that offer them)
+ return all sessions with a matching hash.
+
+ Args:
+ csessid (str): The session hash.
+ Returns:
+ sessions (list): The sessions with matching .csessid, if any.
+
+ """
+ ifcsessid:
+ return[]
+ return[
+ sessionforsessioninself.values()ifsession.csessidandsession.csessid==csessid
+ ]
+
+
[docs]defannounce_all(self,message):
+ """
+ Send message to all connected sessions
+
+ Args:
+ message (str): Message to send.
+
+ """
+ forsessioninself.values():
+ self.data_out(session,text=message)
+
+
[docs]defdata_out(self,session,**kwargs):
+ """
+ Sending data Server -> Portal
+
+ Args:
+ session (Session): Session to relay to.
+ text (str, optional): text data to return
+
+ Notes:
+ The outdata will be scrubbed for sending across
+ the wire here.
+ """
+ # clean output for sending
+ kwargs=self.clean_senddata(session,kwargs)
+
+ # send across AMP
+ self.server.amp_protocol.send_MsgServer2Portal(session,**kwargs)
+
+
[docs]defget_inputfuncs(self):
+ """
+ Get all registered inputfuncs (access function)
+
+ Returns:
+ inputfuncs (dict): A dict of {key:inputfunc,...}
+ """
+ return_INPUT_FUNCS
+
+
[docs]defdata_in(self,session,**kwargs):
+ """
+ We let the data take a "detour" to session.data_in
+ so the user can override and see it all in one place.
+ That method is responsible to in turn always call
+ this class' `sessionhandler.call_inputfunc` with the
+ (possibly processed) data.
+
+ """
+ ifsession:
+ session.data_in(**kwargs)
+
+
[docs]defcall_inputfuncs(self,session,**kwargs):
+ """
+ Split incoming data into its inputfunc counterparts.
+ This should be called by the serversession.data_in
+ as `sessionhandler.call_inputfunc(self, **kwargs)`.
+
+ We also intercept OOB communication here.
+
+ Args:
+ sessions (Session): Session.
+
+ Keyword Args:
+ kwargs (any): Incoming data from protocol on
+ the form `{"commandname": ((args), {kwargs}),...}`
+
+ """
+
+ # distribute incoming data to the correct receiving methods.
+ ifsession:
+ input_debug=session.protocol_flags.get("INPUTDEBUG",False)
+ forcmdname,(cmdargs,cmdkwargs)inkwargs.items():
+ cname=cmdname.strip().lower()
+ try:
+ cmdkwargs.pop("options",None)
+ ifcnamein_INPUT_FUNCS:
+ _INPUT_FUNCS[cname](session,*cmdargs,**cmdkwargs)
+ else:
+ _INPUT_FUNCS["default"](session,cname,*cmdargs,**cmdkwargs)
+ exceptExceptionaserr:
+ ifinput_debug:
+ session.msg(err)
+ log_trace()
[docs]classThrottle(object):
+ """
+ Keeps a running count of failed actions per IP address.
+
+ Available methods indicate whether or not the number of failures exceeds a
+ particular threshold.
+
+ This version of the throttle is usable by both the terminal server as well
+ as the web server, imposes limits on memory consumption by using deques
+ with length limits instead of open-ended lists, and removes sparse keys when
+ no recent failures have been recorded.
+ """
+
+ error_msg="Too many failed attempts; you must wait a few minutes before trying again."
+
+
[docs]def__init__(self,**kwargs):
+ """
+ Allows setting of throttle parameters.
+
+ Keyword Args:
+ limit (int): Max number of failures before imposing limiter
+ timeout (int): number of timeout seconds after
+ max number of tries has been reached.
+ cache_size (int): Max number of attempts to record per IP within a
+ rolling window; this is NOT the same as the limit after which
+ the throttle is imposed!
+ """
+ self.storage=defaultdict(deque)
+ self.cache_size=self.limit=kwargs.get("limit",5)
+ self.timeout=kwargs.get("timeout",5*60)
+
+
[docs]defget(self,ip=None):
+ """
+ Convenience function that returns the storage table, or part of.
+
+ Args:
+ ip (str, optional): IP address of requestor
+
+ Returns:
+ storage (dict): When no IP is provided, returns a dict of all
+ current IPs being tracked and the timestamps of their recent
+ failures.
+ timestamps (deque): When an IP is provided, returns a deque of
+ timestamps of recent failures only for that IP.
+
+ """
+ ifip:
+ returnself.storage.get(ip,deque(maxlen=self.cache_size))
+ else:
+ returnself.storage
+
+
[docs]defupdate(self,ip,failmsg="Exceeded threshold."):
+ """
+ Store the time of the latest failure.
+
+ Args:
+ ip (str): IP address of requestor
+ failmsg (str, optional): Message to display in logs upon activation
+ of throttle.
+
+ Returns:
+ None
+
+ """
+ # Get current status
+ previously_throttled=self.check(ip)
+
+ # Enforce length limits
+ ifnotself.storage[ip].maxlen:
+ self.storage[ip]=deque(maxlen=self.cache_size)
+
+ self.storage[ip].append(time.time())
+
+ # See if this update caused a change in status
+ currently_throttled=self.check(ip)
+
+ # If this makes it engage, log a single activation event
+ ifnotpreviously_throttledandcurrently_throttled:
+ logger.log_sec(
+ "Throttle Activated: %s (IP: %s, %i hits in %i seconds.)"
+ %(failmsg,ip,self.limit,self.timeout)
+ )
+
+
[docs]defcheck(self,ip):
+ """
+ This will check the session's address against the
+ storage dictionary to check they haven't spammed too many
+ fails recently.
+
+ Args:
+ ip (str): IP address of requestor
+
+ Returns:
+ throttled (bool): True if throttling is active,
+ False otherwise.
+
+ """
+ now=time.time()
+ ip=str(ip)
+
+ # checking mode
+ latest_fails=self.storage[ip]
+ iflatest_failsandlen(latest_fails)>=self.limit:
+ # too many fails recently
+ ifnow-latest_fails[-1]<self.timeout:
+ # too soon - timeout in play
+ returnTrue
+ else:
+ # timeout has passed. clear faillist
+ delself.storage[ip]
+ returnFalse
+ else:
+ returnFalse
[docs]classEvenniaUsernameAvailabilityValidator:
+ """
+ Checks to make sure a given username is not taken or otherwise reserved.
+ """
+
+ def__call__(self,username):
+ """
+ Validates a username to make sure it is not in use or reserved.
+
+ Args:
+ username (str): Username to validate
+
+ Returns:
+ None (None): None if password successfully validated,
+ raises ValidationError otherwise.
+
+ """
+
+ # Check guest list
+ ifsettings.GUEST_LISTandusername.lower()in(
+ guest.lower()forguestinsettings.GUEST_LIST
+ ):
+ raiseValidationError(
+ _("Sorry, that username is reserved."),code="evennia_username_reserved"
+ )
+
+ # Check database
+ exists=AccountDB.objects.filter(username__iexact=username).exists()
+ ifexists:
+ raiseValidationError(
+ _("Sorry, that username is already taken."),code="evennia_username_taken"
+ )
[docs]def__init__(
+ self,
+ regex=r"^[\w. @+\-',]+$",
+ policy="Password should contain a mix of letters, "
+ "spaces, digits and @/./+/-/_/'/, only.",
+ ):
+ """
+ Constructs a standard Django password validator.
+
+ Args:
+ regex (str): Regex pattern of valid characters to allow.
+ policy (str): Brief explanation of what the defined regex permits.
+
+ """
+ self.regex=regex
+ self.policy=policy
+
+
[docs]defvalidate(self,password,user=None):
+ """
+ Validates a password string to make sure it meets predefined Evennia
+ acceptable character policy.
+
+ Args:
+ password (str): Password to validate
+ user (None): Unused argument but required by Django
+
+ Returns:
+ None (None): None if password successfully validated,
+ raises ValidationError otherwise.
+
+ """
+ # Check complexity
+ ifnotre.findall(self.regex,password):
+ raiseValidationError(_(self.policy),code="evennia_password_policy")
+
+
[docs]defget_help_text(self):
+ """
+ Returns a user-facing explanation of the password policy defined
+ by this validator.
+
+ Returns:
+ text (str): Explanation of password policy.
+
+ """
+ return_(
+ "%s From a terminal client, you can also use a phrase of multiple words if "
+ "you enclose the password in double quotes."%self.policy
+ )
+"""
+This implements resources for Twisted webservers using the WSGI
+interface of Django. This alleviates the need of running e.g. an
+Apache server to serve Evennia's web presence (although you could do
+that too if desired).
+
+The actual servers are started inside server.py as part of the Evennia
+application.
+
+(Lots of thanks to http://github.com/clemesha/twisted-wsgi-django for
+a great example/aid on how to do this.)
+
+
+"""
+importurllib.parse
+fromurllib.parseimportquoteasurlquote
+fromtwisted.webimportresource,http,server,static
+fromtwisted.internetimportreactor
+fromtwisted.applicationimportinternet
+fromtwisted.web.proxyimportReverseProxyResource
+fromtwisted.web.serverimportNOT_DONE_YET
+fromtwisted.pythonimportthreadpool
+fromtwisted.internetimportdefer
+
+fromtwisted.web.wsgiimportWSGIResource
+fromdjango.confimportsettings
+fromdjango.core.wsgiimportget_wsgi_application
+
+
+fromevennia.utilsimportlogger
+
+_UPSTREAM_IPS=settings.UPSTREAM_IPS
+_DEBUG=settings.DEBUG
+
+
+
[docs]classLockableThreadPool(threadpool.ThreadPool):
+ """
+ Threadpool that can be locked from accepting new requests.
+ """
+
+
[docs]defcallInThread(self,func,*args,**kwargs):
+ """
+ called in the main reactor thread. Makes sure the pool
+ is not locked before continuing.
+ """
+ ifself._accept_new:
+ threadpool.ThreadPool.callInThread(self,func,*args,**kwargs)
[docs]defgetChild(self,path,request):
+ """
+ Create and return a proxy resource with the same proxy configuration
+ as this one, except that its path also contains the segment given by
+ path at the end.
+
+ Args:
+ path (str): Url path.
+ request (Request object): Incoming request.
+
+ Return:
+ resource (EvenniaReverseProxyResource): A proxy resource.
+
+ """
+ request.notifyFinish().addErrback(
+ lambdaf:0
+ # lambda f: logger.log_trace("%s\nCaught errback in webserver.py" % f)
+ )
+ returnEvenniaReverseProxyResource(
+ self.host,self.port,self.path+"/"+urlquote(path,safe=""),self.reactor
+ )
+
+
[docs]defrender(self,request):
+ """
+ Render a request by forwarding it to the proxied server.
+
+ Args:
+ request (Request): Incoming request.
+
+ Returns:
+ not_done (char): Indicator to note request not yet finished.
+
+ """
+ # RFC 2616 tells us that we can omit the port if it's the default port,
+ # but we have to provide it otherwise
+ request.content.seek(0,0)
+ qs=urllib.parse.urlparse(request.uri)[4]
+ ifqs:
+ rest=self.path+"?"+qs.decode()
+ else:
+ rest=self.path
+ rest=rest.encode()
+ clientFactory=self.proxyClientFactoryClass(
+ request.method,
+ rest,
+ request.clientproto,
+ request.getAllHeaders(),
+ request.content.read(),
+ request,
+ )
+ clientFactory.noisy=False
+ self.reactor.connectTCP(self.host,self.port,clientFactory)
+ # don't trigger traceback if connection is lost before request finish.
+ request.notifyFinish().addErrback(lambdaf:0)
+ # request.notifyFinish().addErrback(
+ # lambda f:logger.log_trace("Caught errback in webserver.py: %s" % f)
+ returnNOT_DONE_YET
+
+
+#
+# Website server resource
+#
+
+
+
[docs]classDjangoWebRoot(resource.Resource):
+ """
+ This creates a web root (/) that Django
+ understands by tweaking the way
+ child instances are recognized.
+ """
+
+
[docs]def__init__(self,pool):
+ """
+ Setup the django+twisted resource.
+
+ Args:
+ pool (ThreadPool): The twisted threadpool.
+
+ """
+ self.pool=pool
+ self._echo_log=True
+ self._pending_requests={}
+ super().__init__()
+ self.wsgi_resource=WSGIResource(reactor,pool,get_wsgi_application())
+
+
[docs]defempty_threadpool(self):
+ """
+ Converts our _pending_requests list of deferreds into a DeferredList
+
+ Returns:
+ deflist (DeferredList): Contains all deferreds of pending requests.
+
+ """
+ self.pool.lock()
+ ifself._pending_requestsandself._echo_log:
+ self._echo_log=False# just to avoid multiple echoes
+ msg="Webserver waiting for %i requests ... "
+ logger.log_info(msg%len(self._pending_requests))
+ returndefer.DeferredList(self._pending_requests,consumeErrors=True)
[docs]defgetChild(self,path,request):
+ """
+ To make things work we nudge the url tree to make this the
+ root.
+
+ Args:
+ path (str): Url path.
+ request (Request object): Incoming request.
+
+ Notes:
+ We make sure to save the request queue so
+ that we can safely kill the threadpool
+ on a server reload.
+
+ """
+ path0=request.prepath.pop(0)
+ request.postpath.insert(0,path0)
+
+ request.notifyFinish().addErrback(
+ lambdaf:0
+ # lambda f: logger.log_trace("%s\nCaught errback in webserver.py:" % f)
+ )
+
+ deferred=request.notifyFinish()
+ self._pending_requests[deferred]=deferred
+ deferred.addBoth(self._decrement_requests,deferred=deferred)
+
+ returnself.wsgi_resource
+
+
+#
+# Site with deactivateable logging
+#
+
+
+
[docs]classWebsite(server.Site):
+ """
+ This class will only log http requests if settings.DEBUG is True.
+ """
+
+ noisy=False
+
+
[docs]deflogPrefix(self):
+ "How to be named in logs"
+ ifhasattr(self,"is_portal")andself.is_portal:
+ return"Webserver-proxy"
+ return"Webserver"
[docs]classWSGIWebServer(internet.TCPServer):
+ """
+ This is a WSGI webserver. It makes sure to start
+ the threadpool after the service itself started,
+ so as to register correctly with the twisted daemon.
+
+ call with WSGIWebServer(threadpool, port, wsgi_resource)
+
+ """
+
+
[docs]def__init__(self,pool,*args,**kwargs):
+ """
+ This just stores the threadpool.
+
+ Args:
+ pool (ThreadPool): The twisted threadpool.
+ args, kwargs (any): Passed on to the TCPServer.
+
+ """
+ self.pool=pool
+ super().__init__(*args,**kwargs)
+
+
[docs]defstartService(self):
+ """
+ Start the pool after the service starts.
+
+ """
+ super().startService()
+ self.pool.start()
+
+
[docs]defstopService(self):
+ """
+ Safely stop the pool after the service stops.
+
+ """
+ super().stopService()
+ self.pool.stop()
+
+
+
[docs]classPrivateStaticRoot(static.File):
+ """
+ This overrides the default static file resource so as to not make the
+ directory listings public (that is, if you go to /media or /static you
+ won't see an index of all static/media files on the server).
+
+ """
+
+
[docs]classTagForm(forms.ModelForm):
+ """
+ This form overrides the base behavior of the ModelForm that would be used for a
+ Tag-through-model. Since the through-models only have access to the foreignkeys of the Tag and
+ the Object that they're attached to, we need to spoof the behavior of it being a form that would
+ correspond to its tag, or the creation of a tag. Instead of being saved, we'll call to the
+ Object's handler, which will handle the creation, change, or deletion of a tag for us, as well
+ as updating the handler's cache so that all changes are instantly updated in-game.
+ """
+
+ tag_key=forms.CharField(
+ label="Tag Name",required=True,help_text="This is the main key identifier"
+ )
+ tag_category=forms.CharField(
+ label="Category",
+ help_text="Used for grouping tags. Unset (default) gives a category of None",
+ required=False,
+ )
+ tag_type=forms.CharField(
+ label="Type",
+ help_text='Internal use. Either unset, "alias" or "permission"',
+ required=False,
+ )
+ tag_data=forms.CharField(
+ label="Data",
+ help_text="Usually unused. Intended for eventual info about the tag itself",
+ required=False,
+ )
+
+
[docs]def__init__(self,*args,**kwargs):
+ """
+ If we have a tag, then we'll prepopulate our instance with the fields we'd expect it
+ to have based on the tag. tag_key, tag_category, tag_type, and tag_data all refer to
+ the corresponding tag fields. The initial data of the form fields will similarly be
+ populated.
+ """
+ super().__init__(*args,**kwargs)
+ tagkey=None
+ tagcategory=None
+ tagtype=None
+ tagdata=None
+ ifhasattr(self.instance,"tag"):
+ tagkey=self.instance.tag.db_key
+ tagcategory=self.instance.tag.db_category
+ tagtype=self.instance.tag.db_tagtype
+ tagdata=self.instance.tag.db_data
+ self.fields["tag_key"].initial=tagkey
+ self.fields["tag_category"].initial=tagcategory
+ self.fields["tag_type"].initial=tagtype
+ self.fields["tag_data"].initial=tagdata
+ self.instance.tag_key=tagkey
+ self.instance.tag_category=tagcategory
+ self.instance.tag_type=tagtype
+ self.instance.tag_data=tagdata
+
+
[docs]defsave(self,commit=True):
+ """
+ One thing we want to do here is the or None checks, because forms are saved with an empty
+ string rather than null from forms, usually, and the Handlers may handle empty strings
+ differently than None objects. So for consistency with how things are handled in game,
+ we'll try to make sure that empty form fields will be None, rather than ''.
+ """
+ # we are spoofing a tag for the Handler that will be called
+ # instance = super().save(commit=False)
+ instance=self.instance
+ instance.tag_key=self.cleaned_data["tag_key"]
+ instance.tag_category=self.cleaned_data["tag_category"]orNone
+ instance.tag_type=self.cleaned_data["tag_type"]orNone
+ instance.tag_data=self.cleaned_data["tag_data"]orNone
+ returninstance
+
+
+
[docs]classTagFormSet(forms.BaseInlineFormSet):
+ """
+ The Formset handles all the inline forms that are grouped together on the change page of the
+ corresponding object. All the tags will appear here, and we'll save them by overriding the
+ formset's save method. The forms will similarly spoof their save methods to return an instance
+ which hasn't been saved to the database, but have the relevant fields filled out based on the
+ contents of the cleaned form. We'll then use that to call to the handler of the corresponding
+ Object, where the handler is an AliasHandler, PermissionsHandler, or TagHandler, based on the
+ type of tag.
+ """
+
+
[docs]defsave(self,commit=True):
+ defget_handler(finished_object):
+ related=getattr(finished_object,self.related_field)
+ try:
+ tagtype=finished_object.tag_type
+ exceptAttributeError:
+ tagtype=finished_object.tag.db_tagtype
+ iftagtype=="alias":
+ handler_name="aliases"
+ eliftagtype=="permission":
+ handler_name="permissions"
+ else:
+ handler_name="tags"
+ returngetattr(related,handler_name)
+
+ instances=super().save(commit=False)
+ # self.deleted_objects is a list created when super of save is called, we'll remove those
+ forobjinself.deleted_objects:
+ handler=get_handler(obj)
+ handler.remove(obj.tag_key,category=obj.tag_category)
+ forinstanceininstances:
+ handler=get_handler(instance)
+ handler.add(instance.tag_key,category=instance.tag_category,data=instance.tag_data)
+
+
+
[docs]classTagInline(admin.TabularInline):
+ """
+ A handler for inline Tags. This class should be subclassed in the admin of your models,
+ and the 'model' and 'related_field' class attributes must be set. model should be the
+ through model (ObjectDB_db_tag', for example), while related field should be the name
+ of the field on that through model which points to the model being used: 'objectdb',
+ 'msg', 'accountdb', etc.
+ """
+
+ # Set this to the through model of your desired M2M when subclassing.
+ model=None
+ form=TagForm
+ formset=TagFormSet
+ related_field=None# Must be 'objectdb', 'accountdb', 'msg', etc. Set when subclassing
+ # raw_id_fields = ('tag',)
+ # readonly_fields = ('tag',)
+ extra=0
+
+
[docs]defget_formset(self,request,obj=None,**kwargs):
+ """
+ get_formset has to return a class, but we need to make the class that we return
+ know about the related_field that we'll use. Returning the class itself rather than
+ a proxy isn't threadsafe, since it'd be the base class and would change if multiple
+ people used the admin at the same time
+ """
+ formset=super().get_formset(request,obj,**kwargs)
+
+ classProxyFormset(formset):
+ pass
+
+ ProxyFormset.related_field=self.related_field
+ returnProxyFormset
+
+
+
[docs]classAttributeForm(forms.ModelForm):
+ """
+ This form overrides the base behavior of the ModelForm that would be used for a Attribute-through-model.
+ Since the through-models only have access to the foreignkeys of the Attribute and the Object that they're
+ attached to, we need to spoof the behavior of it being a form that would correspond to its Attribute,
+ or the creation of an Attribute. Instead of being saved, we'll call to the Object's handler, which will handle
+ the creation, change, or deletion of an Attribute for us, as well as updating the handler's cache so that all
+ changes are instantly updated in-game.
+ """
+
+ attr_key=forms.CharField(
+ label="Attribute Name",required=False,initial="Enter Attribute Name Here"
+ )
+ attr_category=forms.CharField(
+ label="Category",help_text="type of attribute, for sorting",required=False,max_length=128
+ )
+ attr_value=PickledFormField(label="Value",help_text="Value to pickle/save",required=False)
+ attr_type=forms.CharField(
+ label="Type",
+ help_text='Internal use. Either unset (normal Attribute) or "nick"',
+ required=False,
+ max_length=16,
+ )
+ attr_lockstring=forms.CharField(
+ label="Locks",
+ required=False,
+ help_text="Lock string on the form locktype:lockdef;lockfunc:lockdef;...",
+ widget=forms.Textarea(attrs={"rows":1,"cols":8}),
+ )
+
+
[docs]def__init__(self,*args,**kwargs):
+ """
+ If we have an Attribute, then we'll prepopulate our instance with the fields we'd expect it
+ to have based on the Attribute. attr_key, attr_category, attr_value, attr_type,
+ and attr_lockstring all refer to the corresponding Attribute fields. The initial data of the form fields will
+ similarly be populated.
+
+ """
+ super().__init__(*args,**kwargs)
+ attr_key=None
+ attr_category=None
+ attr_value=None
+ attr_type=None
+ attr_lockstring=None
+ ifhasattr(self.instance,"attribute"):
+ attr_key=self.instance.attribute.db_key
+ attr_category=self.instance.attribute.db_category
+ attr_value=self.instance.attribute.db_value
+ attr_type=self.instance.attribute.db_attrtype
+ attr_lockstring=self.instance.attribute.db_lock_storage
+ self.fields["attr_key"].initial=attr_key
+ self.fields["attr_category"].initial=attr_category
+ self.fields["attr_type"].initial=attr_type
+ self.fields["attr_value"].initial=attr_value
+ self.fields["attr_lockstring"].initial=attr_lockstring
+ self.instance.attr_key=attr_key
+ self.instance.attr_category=attr_category
+ self.instance.attr_value=attr_value
+
+ # prevent from being transformed to str
+ ifisinstance(attr_value,(set,_SaverSet)):
+ self.fields["attr_value"].disabled=True
+
+ self.instance.deserialized_value=from_pickle(attr_value)
+ self.instance.attr_type=attr_type
+ self.instance.attr_lockstring=attr_lockstring
+
+
[docs]defsave(self,commit=True):
+ """
+ One thing we want to do here is the or None checks, because forms are saved with an empty
+ string rather than null from forms, usually, and the Handlers may handle empty strings
+ differently than None objects. So for consistency with how things are handled in game,
+ we'll try to make sure that empty form fields will be None, rather than ''.
+ """
+ # we are spoofing an Attribute for the Handler that will be called
+ instance=self.instance
+ instance.attr_key=self.cleaned_data["attr_key"]or"no_name_entered_for_attribute"
+ instance.attr_category=self.cleaned_data["attr_category"]orNone
+ instance.attr_value=self.cleaned_data["attr_value"]
+ # convert the serialized string value into an object, if necessary, for AttributeHandler
+ instance.attr_value=from_pickle(instance.attr_value)
+ instance.attr_type=self.cleaned_data["attr_type"]orNone
+ instance.attr_lockstring=self.cleaned_data["attr_lockstring"]
+ returninstance
+
+
[docs]defclean_attr_value(self):
+ """
+ Prevent certain data-types from being cleaned due to literal_eval
+ failing on them. Otherwise they will be turned into str.
+
+ """
+ data=self.cleaned_data["attr_value"]
+ initial=self.instance.attr_value
+ ifisinstance(initial,(set,_SaverSet,datetime)):
+ returninitial
+ returndata
+
+
+
[docs]classAttributeFormSet(forms.BaseInlineFormSet):
+ """
+ Attribute version of TagFormSet, as above.
+ """
+
+
[docs]defsave(self,commit=True):
+ defget_handler(finished_object):
+ related=getattr(finished_object,self.related_field)
+ try:
+ attrtype=finished_object.attr_type
+ exceptAttributeError:
+ attrtype=finished_object.attribute.db_attrtype
+ ifattrtype=="nick":
+ handler_name="nicks"
+ else:
+ handler_name="attributes"
+ returngetattr(related,handler_name)
+
+ instances=super().save(commit=False)
+ forobjinself.deleted_objects:
+ # self.deleted_objects is a list created when super of save is called, we'll remove those
+ handler=get_handler(obj)
+ handler.remove(obj.attr_key,category=obj.attr_category)
+
+ forinstanceininstances:
+ handler=get_handler(instance)
+
+ value=instance.attr_value
+
+ try:
+ handler.add(
+ instance.attr_key,
+ value,
+ category=instance.attr_category,
+ strattr=False,
+ lockstring=instance.attr_lockstring,
+ )
+ except(TypeError,ValueError):
+ # catch errors in nick templates and continue
+ traceback.print_exc()
+ continue
+
+
+
[docs]classAttributeInline(admin.TabularInline):
+ """
+ A handler for inline Attributes. This class should be subclassed in the admin of your models,
+ and the 'model' and 'related_field' class attributes must be set. model should be the
+ through model (ObjectDB_db_tag', for example), while related field should be the name
+ of the field on that through model which points to the model being used: 'objectdb',
+ 'msg', 'accountdb', etc.
+ """
+
+ # Set this to the through model of your desired M2M when subclassing.
+ model=None
+ form=AttributeForm
+ formset=AttributeFormSet
+ related_field=None# Must be 'objectdb', 'accountdb', 'msg', etc. Set when subclassing
+ # raw_id_fields = ('attribute',)
+ # readonly_fields = ('attribute',)
+ extra=0
+
+
[docs]defget_formset(self,request,obj=None,**kwargs):
+ """
+ get_formset has to return a class, but we need to make the class that we return
+ know about the related_field that we'll use. Returning the class itself rather than
+ a proxy isn't threadsafe, since it'd be the base class and would change if multiple
+ people used the admin at the same time
+ """
+ formset=super().get_formset(request,obj,**kwargs)
+
+ classProxyFormset(formset):
+ pass
+
+ ProxyFormset.related_field=self.related_field
+ returnProxyFormset
+"""
+Attributes are arbitrary data stored on objects. Attributes supports
+both pure-string values and pickled arbitrary data.
+
+Attributes are also used to implement Nicks. This module also contains
+the Attribute- and NickHandlers as well as the `NAttributeHandler`,
+which is a non-db version of Attributes.
+
+
+"""
+importre
+importfnmatch
+importweakref
+
+fromdjango.dbimportmodels
+fromdjango.confimportsettings
+fromdjango.utils.encodingimportsmart_str
+
+fromevennia.locks.lockhandlerimportLockHandler
+fromevennia.utils.idmapper.modelsimportSharedMemoryModel
+fromevennia.utils.dbserializeimportto_pickle,from_pickle
+fromevennia.utils.picklefieldimportPickledObjectField
+fromevennia.utils.utilsimportlazy_property,to_str,make_iter,is_iter
+
+_TYPECLASS_AGGRESSIVE_CACHE=settings.TYPECLASS_AGGRESSIVE_CACHE
+
+# -------------------------------------------------------------
+#
+# Attributes
+#
+# -------------------------------------------------------------
+
+
+
[docs]classAttribute(SharedMemoryModel):
+ """
+ Attributes are things that are specific to different types of objects. For
+ example, a drink container needs to store its fill level, whereas an exit
+ needs to store its open/closed/locked/unlocked state. These are done via
+ attributes, rather than making different classes for each object type and
+ storing them directly. The added benefit is that we can add/remove
+ attributes on the fly as we like.
+
+ The Attribute class defines the following properties:
+ - key (str): Primary identifier.
+ - lock_storage (str): Perm strings.
+ - model (str): A string defining the model this is connected to. This
+ is a natural_key, like "objects.objectdb"
+ - date_created (datetime): When the attribute was created.
+ - value (any): The data stored in the attribute, in pickled form
+ using wrappers to be able to store/retrieve models.
+ - strvalue (str): String-only data. This data is not pickled and
+ is thus faster to search for in the database.
+ - category (str): Optional character string for grouping the
+ Attribute.
+
+ """
+
+ #
+ # Attribute Database Model setup
+ #
+ # These database fields are all set using their corresponding properties,
+ # named same as the field, but without the db_* prefix.
+ db_key=models.CharField("key",max_length=255,db_index=True)
+ db_value=PickledObjectField(
+ "value",
+ null=True,
+ help_text="The data returned when the attribute is accessed. Must be "
+ "written as a Python literal if editing through the admin "
+ "interface. Attribute values which are not Python literals "
+ "cannot be edited through the admin interface.",
+ )
+ db_strvalue=models.TextField(
+ "strvalue",null=True,blank=True,help_text="String-specific storage for quick look-up"
+ )
+ db_category=models.CharField(
+ "category",
+ max_length=128,
+ db_index=True,
+ blank=True,
+ null=True,
+ help_text="Optional categorization of attribute.",
+ )
+ # Lock storage
+ db_lock_storage=models.TextField(
+ "locks",blank=True,help_text="Lockstrings for this object are stored here."
+ )
+ db_model=models.CharField(
+ "model",
+ max_length=32,
+ db_index=True,
+ blank=True,
+ null=True,
+ help_text="Which model of object this attribute is attached to (A "
+ "natural key like 'objects.objectdb'). You should not change "
+ "this value unless you know what you are doing.",
+ )
+ # subclass of Attribute (None or nick)
+ db_attrtype=models.CharField(
+ "attrtype",
+ max_length=16,
+ db_index=True,
+ blank=True,
+ null=True,
+ help_text="Subclass of Attribute (None or nick)",
+ )
+ # time stamp
+ db_date_created=models.DateTimeField("date_created",editable=False,auto_now_add=True)
+
+ # Database manager
+ # objects = managers.AttributeManager()
+
+
+
+ classMeta(object):
+ "Define Django meta options"
+ verbose_name="Evennia Attribute"
+
+ # read-only wrappers
+ key=property(lambdaself:self.db_key)
+ strvalue=property(lambdaself:self.db_strvalue)
+ category=property(lambdaself:self.db_category)
+ model=property(lambdaself:self.db_model)
+ attrtype=property(lambdaself:self.db_attrtype)
+ date_created=property(lambdaself:self.db_date_created)
+
+ def__lock_storage_get(self):
+ returnself.db_lock_storage
+
+ def__lock_storage_set(self,value):
+ self.db_lock_storage=value
+ self.save(update_fields=["db_lock_storage"])
+
+ def__lock_storage_del(self):
+ self.db_lock_storage=""
+ self.save(update_fields=["db_lock_storage"])
+
+ lock_storage=property(__lock_storage_get,__lock_storage_set,__lock_storage_del)
+
+ # Wrapper properties to easily set database fields. These are
+ # @property decorators that allows to access these fields using
+ # normal python operations (without having to remember to save()
+ # etc). So e.g. a property 'attr' has a get/set/del decorator
+ # defined that allows the user to do self.attr = value,
+ # value = self.attr and del self.attr respectively (where self
+ # is the object in question).
+
+ # value property (wraps db_value)
+ # @property
+ def__value_get(self):
+ """
+ Getter. Allows for `value = self.value`.
+ We cannot cache here since it makes certain cases (such
+ as storing a dbobj which is then deleted elsewhere) out-of-sync.
+ The overhead of unpickling seems hard to avoid.
+ """
+ returnfrom_pickle(self.db_value,db_obj=self)
+
+ # @value.setter
+ def__value_set(self,new_value):
+ """
+ Setter. Allows for self.value = value. We cannot cache here,
+ see self.__value_get.
+ """
+ self.db_value=to_pickle(new_value)
+ # print("value_set, self.db_value:", repr(self.db_value)) # DEBUG
+ self.save(update_fields=["db_value"])
+
+ # @value.deleter
+ def__value_del(self):
+ """Deleter. Allows for del attr.value. This removes the entire attribute."""
+ self.delete()
+
+ value=property(__value_get,__value_set,__value_del)
+
+ #
+ #
+ # Attribute methods
+ #
+ #
+
+ def__str__(self):
+ returnsmart_str("%s[category=%s](#%s)"%(self.db_key,self.db_category,self.id))
+
+ def__repr__(self):
+ return"%s[category=%s](#%s)"%(self.db_key,self.db_category,self.id)
+
+
[docs]defaccess(self,accessing_obj,access_type="attrread",default=False,**kwargs):
+ """
+ Determines if another object has permission to access.
+
+ Args:
+ accessing_obj (object): Entity trying to access this one.
+ access_type (str, optional): Type of access sought, see
+ the lock documentation.
+ default (bool, optional): What result to return if no lock
+ of access_type was found. The default, `False`, means a lockdown
+ policy, only allowing explicit access.
+ kwargs (any, optional): Not used; here to make the API consistent with
+ other access calls.
+
+ Returns:
+ result (bool): If the lock was passed or not.
+
+ """
+ result=self.locks.check(accessing_obj,access_type=access_type,default=default)
+ returnresult
+
+
+#
+# Handlers making use of the Attribute model
+#
+
+
+
[docs]classAttributeHandler(object):
+ """
+ Handler for adding Attributes to the object.
+ """
+
+ _m2m_fieldname="db_attributes"
+ _attrcreate="attrcreate"
+ _attredit="attredit"
+ _attrread="attrread"
+ _attrtype=None
+
+
[docs]def__init__(self,obj):
+ """Initialize handler."""
+ self.obj=obj
+ self._objid=obj.id
+ self._model=to_str(obj.__dbclass__.__name__.lower())
+ self._cache={}
+ # store category names fully cached
+ self._catcache={}
+ # full cache was run on all attributes
+ self._cache_complete=False
+
+ def_query_all(self):
+ "Fetch all Attributes on this object"
+ query={
+ "%s__id"%self._model:self._objid,
+ "attribute__db_model__iexact":self._model,
+ "attribute__db_attrtype":self._attrtype,
+ }
+ return[
+ conn.attribute
+ forconningetattr(self.obj,self._m2m_fieldname).through.objects.filter(**query)
+ ]
+
+ def_fullcache(self):
+ """Cache all attributes of this object"""
+ ifnot_TYPECLASS_AGGRESSIVE_CACHE:
+ return
+ attrs=self._query_all()
+ self._cache=dict(
+ (
+ "%s-%s"
+ %(
+ to_str(attr.db_key).lower(),
+ attr.db_category.lower()ifattr.db_categoryisnotNoneelseNone,
+ ),
+ attr,
+ )
+ forattrinattrs
+ )
+ self._cache_complete=True
+
+ def_getcache(self,key=None,category=None):
+ """
+ Retrieve from cache or database (always caches)
+
+ Args:
+ key (str, optional): Attribute key to query for
+ category (str, optional): Attribiute category
+
+ Returns:
+ args (list): Returns a list of zero or more matches
+ found from cache or database.
+ Notes:
+ When given a category only, a search for all objects
+ of that cateogory is done and the category *name* is
+ stored. This tells the system on subsequent calls that the
+ list of cached attributes of this category is up-to-date
+ and that the cache can be queried for category matches
+ without missing any.
+ The TYPECLASS_AGGRESSIVE_CACHE=False setting will turn off
+ caching, causing each attribute access to trigger a
+ database lookup.
+
+ """
+ key=key.strip().lower()ifkeyelseNone
+ category=category.strip().lower()ifcategoryisnotNoneelseNone
+ ifkey:
+ cachekey="%s-%s"%(key,category)
+ cachefound=False
+ try:
+ attr=_TYPECLASS_AGGRESSIVE_CACHEandself._cache[cachekey]
+ cachefound=True
+ exceptKeyError:
+ attr=None
+
+ ifattrand(nothasattr(attr,"pk")andattr.pkisNone):
+ # clear out Attributes deleted from elsewhere. We must search this anew.
+ attr=None
+ cachefound=False
+ delself._cache[cachekey]
+ ifcachefoundand_TYPECLASS_AGGRESSIVE_CACHE:
+ ifattr:
+ return[attr]# return cached entity
+ else:
+ return[]# no such attribute: return an empty list
+ else:
+ query={
+ "%s__id"%self._model:self._objid,
+ "attribute__db_model__iexact":self._model,
+ "attribute__db_attrtype":self._attrtype,
+ "attribute__db_key__iexact":key.lower(),
+ "attribute__db_category__iexact":category.lower()ifcategoryelseNone,
+ }
+ ifnotself.obj.pk:
+ return[]
+ conn=getattr(self.obj,self._m2m_fieldname).through.objects.filter(**query)
+ ifconn:
+ attr=conn[0].attribute
+ if_TYPECLASS_AGGRESSIVE_CACHE:
+ self._cache[cachekey]=attr
+ return[attr]ifattr.pkelse[]
+ else:
+ # There is no such attribute. We will explicitly save that
+ # in our cache to avoid firing another query if we try to
+ # retrieve that (non-existent) attribute again.
+ if_TYPECLASS_AGGRESSIVE_CACHE:
+ self._cache[cachekey]=None
+ return[]
+ else:
+ # only category given (even if it's None) - we can't
+ # assume the cache to be complete unless we have queried
+ # for this category before
+ catkey="-%s"%category
+ if_TYPECLASS_AGGRESSIVE_CACHEandcatkeyinself._catcache:
+ return[attrforkey,attrinself._cache.items()ifkey.endswith(catkey)andattr]
+ else:
+ # we have to query to make this category up-date in the cache
+ query={
+ "%s__id"%self._model:self._objid,
+ "attribute__db_model__iexact":self._model,
+ "attribute__db_attrtype":self._attrtype,
+ "attribute__db_category__iexact":category.lower()ifcategoryelseNone,
+ }
+ attrs=[
+ conn.attribute
+ forconningetattr(self.obj,self._m2m_fieldname).through.objects.filter(
+ **query
+ )
+ ]
+ if_TYPECLASS_AGGRESSIVE_CACHE:
+ forattrinattrs:
+ ifattr.pk:
+ cachekey="%s-%s"%(attr.db_key,category)
+ self._cache[cachekey]=attr
+ # mark category cache as up-to-date
+ self._catcache[catkey]=True
+ returnattrs
+
+ def_setcache(self,key,category,attr_obj):
+ """
+ Update cache.
+
+ Args:
+ key (str): A cleaned key string
+ category (str or None): A cleaned category name
+ attr_obj (Attribute): The newly saved attribute
+
+ """
+ ifnot_TYPECLASS_AGGRESSIVE_CACHE:
+ return
+ ifnotkey:# don't allow an empty key in cache
+ return
+ cachekey="%s-%s"%(key,category)
+ catkey="-%s"%category
+ self._cache[cachekey]=attr_obj
+ # mark that the category cache is no longer up-to-date
+ self._catcache.pop(catkey,None)
+ self._cache_complete=False
+
+ def_delcache(self,key,category):
+ """
+ Remove attribute from cache
+
+ Args:
+ key (str): A cleaned key string
+ category (str or None): A cleaned category name
+
+ """
+ catkey="-%s"%category
+ ifkey:
+ cachekey="%s-%s"%(key,category)
+ self._cache.pop(cachekey,None)
+ else:
+ self._cache={
+ key:attrobj
+ forkey,attrobjinlist(self._cache.items())
+ ifnotkey.endswith(catkey)
+ }
+ # mark that the category cache is no longer up-to-date
+ self._catcache.pop(catkey,None)
+ self._cache_complete=False
+
+
[docs]defreset_cache(self):
+ """
+ Reset cache from the outside.
+ """
+ self._cache_complete=False
+ self._cache={}
+ self._catcache={}
+
+
[docs]defhas(self,key=None,category=None):
+ """
+ Checks if the given Attribute (or list of Attributes) exists on
+ the object.
+
+ Args:
+ key (str or iterable): The Attribute key or keys to check for.
+ If `None`, search by category.
+ category (str or None): Limit the check to Attributes with this
+ category (note, that `None` is the default category).
+
+ Returns:
+ has_attribute (bool or list): If the Attribute exists on
+ this object or not. If `key` was given as an iterable then
+ the return is a list of booleans.
+
+ """
+ ret=[]
+ category=category.strip().lower()ifcategoryisnotNoneelseNone
+ forkeystrinmake_iter(key):
+ keystr=key.strip().lower()
+ ret.extend(bool(attr)forattrinself._getcache(keystr,category))
+ returnret[0]iflen(ret)==1elseret
+
+
[docs]defget(
+ self,
+ key=None,
+ default=None,
+ category=None,
+ return_obj=False,
+ strattr=False,
+ raise_exception=False,
+ accessing_obj=None,
+ default_access=True,
+ return_list=False,
+ ):
+ """
+ Get the Attribute.
+
+ Args:
+ key (str or list, optional): the attribute identifier or
+ multiple attributes to get. if a list of keys, the
+ method will return a list.
+ category (str, optional): the category within which to
+ retrieve attribute(s).
+ default (any, optional): The value to return if an
+ Attribute was not defined. If set, it will be returned in
+ a one-item list.
+ return_obj (bool, optional): If set, the return is not the value of the
+ Attribute but the Attribute object itself.
+ strattr (bool, optional): Return the `strvalue` field of
+ the Attribute rather than the usual `value`, this is a
+ string-only value for quick database searches.
+ raise_exception (bool, optional): When an Attribute is not
+ found, the return from this is usually `default`. If this
+ is set, an exception is raised instead.
+ accessing_obj (object, optional): If set, an `attrread`
+ permission lock will be checked before returning each
+ looked-after Attribute.
+ default_access (bool, optional): If no `attrread` lock is set on
+ object, this determines if the lock should then be passed or not.
+ return_list (bool, optional):
+
+ Returns:
+ result (any or list): One or more matches for keys and/or
+ categories. Each match will be the value of the found Attribute(s)
+ unless `return_obj` is True, at which point it will be the
+ attribute object itself or None. If `return_list` is True, this
+ will always be a list, regardless of the number of elements.
+
+ Raises:
+ AttributeError: If `raise_exception` is set and no matching Attribute
+ was found matching `key`.
+
+ """
+
+ ret=[]
+ forkeystrinmake_iter(key):
+ # it's okay to send a None key
+ attr_objs=self._getcache(keystr,category)
+ ifattr_objs:
+ ret.extend(attr_objs)
+ elifraise_exception:
+ raiseAttributeError
+ elifreturn_obj:
+ ret.append(None)
+
+ ifaccessing_obj:
+ # check 'attrread' locks
+ ret=[
+ attr
+ forattrinret
+ ifattr.access(accessing_obj,self._attrread,default=default_access)
+ ]
+ ifstrattr:
+ ret=retifreturn_objelse[attr.strvalueforattrinretifattr]
+ else:
+ ret=retifreturn_objelse[attr.valueforattrinretifattr]
+
+ ifreturn_list:
+ returnretifretelse[default]ifdefaultisnotNoneelse[]
+ returnret[0]ifretandlen(ret)==1elseretordefault
+
+
[docs]defadd(
+ self,
+ key,
+ value,
+ category=None,
+ lockstring="",
+ strattr=False,
+ accessing_obj=None,
+ default_access=True,
+ ):
+ """
+ Add attribute to object, with optional `lockstring`.
+
+ Args:
+ key (str): An Attribute name to add.
+ value (any or str): The value of the Attribute. If
+ `strattr` keyword is set, this *must* be a string.
+ category (str, optional): The category for the Attribute.
+ The default `None` is the normal category used.
+ lockstring (str, optional): A lock string limiting access
+ to the attribute.
+ strattr (bool, optional): Make this a string-only Attribute.
+ This is only ever useful for optimization purposes.
+ accessing_obj (object, optional): An entity to check for
+ the `attrcreate` access-type. If not passing, this method
+ will be exited.
+ default_access (bool, optional): What access to grant if
+ `accessing_obj` is given but no lock of the type
+ `attrcreate` is defined on the Attribute in question.
+
+ """
+ ifaccessing_objandnotself.obj.access(
+ accessing_obj,self._attrcreate,default=default_access
+ ):
+ # check create access
+ return
+
+ ifnotkey:
+ return
+
+ category=category.strip().lower()ifcategoryisnotNoneelseNone
+
+ keystr=key.strip().lower()
+ attr_obj=self._getcache(key,category)
+
+ ifattr_obj:
+ # update an existing attribute object
+ attr_obj=attr_obj[0]
+ ifstrattr:
+ # store as a simple string (will not notify OOB handlers)
+ attr_obj.db_strvalue=value
+ attr_obj.save(update_fields=["db_strvalue"])
+ else:
+ # store normally (this will also notify OOB handlers)
+ attr_obj.value=value
+ else:
+ # create a new Attribute (no OOB handlers can be notified)
+ kwargs={
+ "db_key":keystr,
+ "db_category":category,
+ "db_model":self._model,
+ "db_attrtype":self._attrtype,
+ "db_value":Noneifstrattrelseto_pickle(value),
+ "db_strvalue":valueifstrattrelseNone,
+ }
+ new_attr=Attribute(**kwargs)
+ new_attr.save()
+ getattr(self.obj,self._m2m_fieldname).add(new_attr)
+ # update cache
+ self._setcache(keystr,category,new_attr)
+
+
[docs]defbatch_add(self,*args,**kwargs):
+ """
+ Batch-version of `add()`. This is more efficient than
+ repeat-calling add when having many Attributes to add.
+
+ Args:
+ *args (tuple): Each argument should be a tuples (can be of varying
+ length) representing the Attribute to add to this object.
+ Supported tuples are
+
+ - `(key, value)`
+ - `(key, value, category)`
+ - `(key, value, category, lockstring)`
+ - `(key, value, category, lockstring, default_access)`
+
+ Keyword Args:
+ strattr (bool): If `True`, value must be a string. This
+ will save the value without pickling which is less
+ flexible but faster to search (not often used except
+ internally).
+
+ Raises:
+ RuntimeError: If trying to pass a non-iterable as argument.
+
+ Notes:
+ The indata tuple order matters, so if you want a lockstring
+ but no category, set the category to `None`. This method
+ does not have the ability to check editing permissions like
+ normal .add does, and is mainly used internally. It does not
+ use the normal self.add but apply the Attributes directly
+ to the database.
+
+ """
+ new_attrobjs=[]
+ strattr=kwargs.get("strattr",False)
+ fortupinargs:
+ ifnotis_iter(tup)orlen(tup)<2:
+ raiseRuntimeError("batch_add requires iterables as arguments (got %r)."%tup)
+ ntup=len(tup)
+ keystr=str(tup[0]).strip().lower()
+ new_value=tup[1]
+ category=str(tup[2]).strip().lower()ifntup>2andtup[2]isnotNoneelseNone
+ lockstring=tup[3]ifntup>3else""
+
+ attr_objs=self._getcache(keystr,category)
+
+ ifattr_objs:
+ attr_obj=attr_objs[0]
+ # update an existing attribute object
+ attr_obj.db_category=category
+ attr_obj.db_lock_storage=lockstringor""
+ attr_obj.save(update_fields=["db_category","db_lock_storage"])
+ ifstrattr:
+ # store as a simple string (will not notify OOB handlers)
+ attr_obj.db_strvalue=new_value
+ attr_obj.save(update_fields=["db_strvalue"])
+ else:
+ # store normally (this will also notify OOB handlers)
+ attr_obj.value=new_value
+ else:
+ # create a new Attribute (no OOB handlers can be notified)
+ kwargs={
+ "db_key":keystr,
+ "db_category":category,
+ "db_model":self._model,
+ "db_attrtype":self._attrtype,
+ "db_value":Noneifstrattrelseto_pickle(new_value),
+ "db_strvalue":new_valueifstrattrelseNone,
+ "db_lock_storage":lockstringor"",
+ }
+ new_attr=Attribute(**kwargs)
+ new_attr.save()
+ new_attrobjs.append(new_attr)
+ self._setcache(keystr,category,new_attr)
+ ifnew_attrobjs:
+ # Add new objects to m2m field all at once
+ getattr(self.obj,self._m2m_fieldname).add(*new_attrobjs)
+
+
[docs]defremove(
+ self,
+ key=None,
+ raise_exception=False,
+ category=None,
+ accessing_obj=None,
+ default_access=True,
+ ):
+ """
+ Remove attribute or a list of attributes from object.
+
+ Args:
+ key (str or list, optional): An Attribute key to remove or a list of keys. If
+ multiple keys, they must all be of the same `category`. If None and
+ category is not given, remove all Attributes.
+ raise_exception (bool, optional): If set, not finding the
+ Attribute to delete will raise an exception instead of
+ just quietly failing.
+ category (str, optional): The category within which to
+ remove the Attribute.
+ accessing_obj (object, optional): An object to check
+ against the `attredit` lock. If not given, the check will
+ be skipped.
+ default_access (bool, optional): The fallback access to
+ grant if `accessing_obj` is given but there is no
+ `attredit` lock set on the Attribute in question.
+
+ Raises:
+ AttributeError: If `raise_exception` is set and no matching Attribute
+ was found matching `key`.
+
+ Notes:
+ If neither key nor category is given, this acts as clear().
+
+ """
+
+ ifkeyisNone:
+ self.clear(
+ category=category,accessing_obj=accessing_obj,default_access=default_access
+ )
+ return
+
+ category=category.strip().lower()ifcategoryisnotNoneelseNone
+
+ forkeystrinmake_iter(key):
+ keystr=keystr.lower()
+
+ attr_objs=self._getcache(keystr,category)
+ forattr_objinattr_objs:
+ ifnot(
+ accessing_obj
+ andnotattr_obj.access(accessing_obj,self._attredit,default=default_access)
+ ):
+ try:
+ attr_obj.delete()
+ exceptAssertionError:
+ print("Assertionerror for attr.delete()")
+ # this happens if the attr was already deleted
+ pass
+ finally:
+ self._delcache(keystr,category)
+ ifnotattr_objsandraise_exception:
+ raiseAttributeError
+
+
[docs]defclear(self,category=None,accessing_obj=None,default_access=True):
+ """
+ Remove all Attributes on this object.
+
+ Args:
+ category (str, optional): If given, clear only Attributes
+ of this category.
+ accessing_obj (object, optional): If given, check the
+ `attredit` lock on each Attribute before continuing.
+ default_access (bool, optional): Use this permission as
+ fallback if `access_obj` is given but there is no lock of
+ type `attredit` on the Attribute in question.
+
+ """
+ category=category.strip().lower()ifcategoryisnotNoneelseNone
+
+ ifnotself._cache_complete:
+ self._fullcache()
+
+ ifcategoryisnotNone:
+ attrs=[attrforattrinself._cache.values()ifattr.category==category]
+ else:
+ attrs=self._cache.values()
+
+ ifaccessing_obj:
+ [
+ attr.delete()
+ forattrinattrs
+ ifattrandattr.access(accessing_obj,self._attredit,default=default_access)
+ ]
+ else:
+ [attr.delete()forattrinattrsifattrandattr.pk]
+ self._cache={}
+ self._catcache={}
+ self._cache_complete=False
+
+
[docs]defall(self,accessing_obj=None,default_access=True):
+ """
+ Return all Attribute objects on this object, regardless of category.
+
+ Args:
+ accessing_obj (object, optional): Check the `attrread`
+ lock on each attribute before returning them. If not
+ given, this check is skipped.
+ default_access (bool, optional): Use this permission as a
+ fallback if `accessing_obj` is given but one or more
+ Attributes has no lock of type `attrread` defined on them.
+
+ Returns:
+ Attributes (list): All the Attribute objects (note: Not
+ their values!) in the handler.
+
+ """
+ if_TYPECLASS_AGGRESSIVE_CACHE:
+ ifnotself._cache_complete:
+ self._fullcache()
+ attrs=sorted([attrforattrinself._cache.values()ifattr],key=lambdao:o.id)
+ else:
+ attrs=sorted([attrforattrinself._query_all()ifattr],key=lambdao:o.id)
+
+ ifaccessing_obj:
+ return[
+ attr
+ forattrinattrs
+ ifattr.access(accessing_obj,self._attredit,default=default_access)
+ ]
+ else:
+ returnattrs
+
+
+# Nick templating
+#
+
+"""
+This supports the use of replacement templates in nicks:
+
+This happens in two steps:
+
+1) The user supplies a template that is converted to a regex according
+ to the unix-like templating language.
+2) This regex is tested against nicks depending on which nick replacement
+ strategy is considered (most commonly inputline).
+3) If there is a template match and there are templating markers,
+ these are replaced with the arguments actually given.
+
+@desc $1 $2 $3
+
+This will be converted to the following regex:
+
+\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+)
+
+Supported template markers (through fnmatch)
+ * matches anything (non-greedy) -> .*?
+ ? matches any single character ->
+ [seq] matches any entry in sequence
+ [!seq] matches entries not in sequence
+Custom arg markers
+ $N argument position (1-99)
+
+"""
+_RE_NICK_ARG=re.compile(r"\\(\$)([1-9][0-9]?)")
+_RE_NICK_TEMPLATE_ARG=re.compile(r"(\$)([1-9][0-9]?)")
+_RE_NICK_SPACE=re.compile(r"\\ ")
+
+
+
[docs]definitialize_nick_templates(in_template,out_template):
+ """
+ Initialize the nick templates for matching and remapping a string.
+
+ Args:
+ in_template (str): The template to be used for nick recognition.
+ out_template (str): The template to be used to replace the string
+ matched by the in_template.
+
+ Returns:
+ (regex, str): Regex to match against strings and a template
+ Template with markers `{arg1}`, `{arg2}`, etc for
+ replacement using the standard `.format` method.
+
+ Raises:
+ attributes.NickTemplateInvalid: If the in/out template does not have a matching
+ number of $args.
+
+ """
+
+ # create the regex for in_template
+ regex_string=fnmatch.translate(in_template)
+ # we must account for a possible line break coming over the wire
+
+ # NOTE-PYTHON3: fnmatch.translate format changed since Python2
+ regex_string=regex_string[:-2]+r"(?:[\n\r]*?)\Z"
+
+ # validate the templates
+ regex_args=[match.group(2)formatchin_RE_NICK_ARG.finditer(regex_string)]
+ temp_args=[match.group(2)formatchin_RE_NICK_TEMPLATE_ARG.finditer(out_template)]
+ ifset(regex_args)!=set(temp_args):
+ # We don't have the same $-tags in input/output.
+ raiseNickTemplateInvalid
+
+ regex_string=_RE_NICK_SPACE.sub(r"\\s+",regex_string)
+ regex_string=_RE_NICK_ARG.sub(lambdam:"(?P<arg%s>.+?)"%m.group(2),regex_string)
+ template_string=_RE_NICK_TEMPLATE_ARG.sub(lambdam:"{arg%s}"%m.group(2),out_template)
+
+ returnregex_string,template_string
+
+
+
[docs]defparse_nick_template(string,template_regex,outtemplate):
+ """
+ Parse a text using a template and map it to another template
+
+ Args:
+ string (str): The input string to processj
+ template_regex (regex): A template regex created with
+ initialize_nick_template.
+ outtemplate (str): The template to which to map the matches
+ produced by the template_regex. This should have $1, $2,
+ etc to match the regex.
+
+ """
+ match=template_regex.match(string)
+ ifmatch:
+ returnTrue,outtemplate.format(**match.groupdict())
+ returnFalse,string
+
+
+
[docs]classNickHandler(AttributeHandler):
+ """
+ Handles the addition and removal of Nicks. Nicks are special
+ versions of Attributes with an `_attrtype` hardcoded to `nick`.
+ They also always use the `strvalue` fields for their data.
+
+ """
+
+ _attrtype="nick"
+
+
[docs]defhas(self,key,category="inputline"):
+ """
+ Args:
+ key (str or iterable): The Nick key or keys to check for.
+ category (str): Limit the check to Nicks with this
+ category (note, that `None` is the default category).
+
+ Returns:
+ has_nick (bool or list): If the Nick exists on this object
+ or not. If `key` was given as an iterable then the return
+ is a list of booleans.
+
+ """
+ returnsuper().has(key,category=category)
+
+
[docs]defget(self,key=None,category="inputline",return_tuple=False,**kwargs):
+ """
+ Get the replacement value matching the given key and category
+
+ Args:
+ key (str or list, optional): the attribute identifier or
+ multiple attributes to get. if a list of keys, the
+ method will return a list.
+ category (str, optional): the category within which to
+ retrieve the nick. The "inputline" means replacing data
+ sent by the user.
+ return_tuple (bool, optional): return the full nick tuple rather
+ than just the replacement. For non-template nicks this is just
+ a string.
+ kwargs (any, optional): These are passed on to `AttributeHandler.get`.
+
+ """
+ ifreturn_tupleor"return_obj"inkwargs:
+ returnsuper().get(key=key,category=category,**kwargs)
+ else:
+ retval=super().get(key=key,category=category,**kwargs)
+ ifretval:
+ return(
+ retval[3]
+ ifisinstance(retval,tuple)
+ else[tup[3]fortupinmake_iter(retval)]
+ )
+ returnNone
+
+
[docs]defadd(self,key,replacement,category="inputline",**kwargs):
+ """
+ Add a new nick.
+
+ Args:
+ key (str): A key (or template) for the nick to match for.
+ replacement (str): The string (or template) to replace `key` with (the "nickname").
+ category (str, optional): the category within which to
+ retrieve the nick. The "inputline" means replacing data
+ sent by the user.
+ kwargs (any, optional): These are passed on to `AttributeHandler.get`.
+
+ """
+ ifcategory=="channel":
+ nick_regex,nick_template=initialize_nick_templates(key+" $1",replacement+" $1")
+ else:
+ nick_regex,nick_template=initialize_nick_templates(key,replacement)
+ super().add(key,(nick_regex,nick_template,key,replacement),category=category,**kwargs)
+
+
[docs]defremove(self,key,category="inputline",**kwargs):
+ """
+ Remove Nick with matching category.
+
+ Args:
+ key (str): A key for the nick to match for.
+ category (str, optional): the category within which to
+ removethe nick. The "inputline" means replacing data
+ sent by the user.
+ kwargs (any, optional): These are passed on to `AttributeHandler.get`.
+
+ """
+ super().remove(key,category=category,**kwargs)
+
+
[docs]defnickreplace(self,raw_string,categories=("inputline","channel"),include_account=True):
+ """
+ Apply nick replacement of entries in raw_string with nick replacement.
+
+ Args:
+ raw_string (str): The string in which to perform nick
+ replacement.
+ categories (tuple, optional): Replacement categories in
+ which to perform the replacement, such as "inputline",
+ "channel" etc.
+ include_account (bool, optional): Also include replacement
+ with nicks stored on the Account level.
+ kwargs (any, optional): Not used.
+
+ Returns:
+ string (str): A string with matching keys replaced with
+ their nick equivalents.
+
+ """
+ nicks={}
+ forcategoryinmake_iter(categories):
+ nicks.update(
+ {
+ nick.key:nick
+ fornickinmake_iter(self.get(category=category,return_obj=True))
+ ifnickandnick.key
+ }
+ )
+ ifinclude_accountandself.obj.has_account:
+ forcategoryinmake_iter(categories):
+ nicks.update(
+ {
+ nick.key:nick
+ fornickinmake_iter(
+ self.obj.account.nicks.get(category=category,return_obj=True)
+ )
+ ifnickandnick.key
+ }
+ )
+ forkey,nickinnicks.items():
+ nick_regex,template,_,_=nick.value
+ regex=self._regex_cache.get(nick_regex)
+ ifnotregex:
+ regex=re.compile(nick_regex,re.I+re.DOTALL+re.U)
+ self._regex_cache[nick_regex]=regex
+
+ is_match,raw_string=parse_nick_template(raw_string.strip(),regex,template)
+ ifis_match:
+ break
+ returnraw_string
+
+
+
[docs]classNAttributeHandler(object):
+ """
+ This stand-alone handler manages non-database saving.
+ It is similar to `AttributeHandler` and is used
+ by the `.ndb` handler in the same way as `.db` does
+ for the `AttributeHandler`.
+ """
+
+
[docs]def__init__(self,obj):
+ """
+ Initialized on the object
+ """
+ self._store={}
+ self.obj=weakref.proxy(obj)
+
+
[docs]defhas(self,key):
+ """
+ Check if object has this attribute or not.
+
+ Args:
+ key (str): The Nattribute key to check.
+
+ Returns:
+ has_nattribute (bool): If Nattribute is set or not.
+
+ """
+ returnkeyinself._store
+
+
[docs]defget(self,key):
+ """
+ Get the named key value.
+
+ Args:
+ key (str): The Nattribute key to get.
+
+ Returns:
+ the value of the Nattribute.
+
+ """
+ returnself._store.get(key,None)
+
+
[docs]defadd(self,key,value):
+ """
+ Add new key and value.
+
+ Args:
+ key (str): The name of Nattribute to add.
+ value (any): The value to store.
+
+ """
+ self._store[key]=value
+
+
[docs]defremove(self,key):
+ """
+ Remove Nattribute from storage.
+
+ Args:
+ key (str): The name of the Nattribute to remove.
+
+ """
+ ifkeyinself._store:
+ delself._store[key]
+
+
[docs]defclear(self):
+ """
+ Remove all NAttributes from handler.
+
+ """
+ self._store={}
+
+
[docs]defall(self,return_tuples=False):
+ """
+ List the contents of the handler.
+
+ Args:
+ return_tuples (bool, optional): Defines if the Nattributes
+ are returns as a list of keys or as a list of `(key, value)`.
+
+ Returns:
+ nattributes (list): A list of keys `[key, key, ...]` or a
+ list of tuples `[(key, value), ...]` depending on the
+ setting of `return_tuples`.
+
+ """
+ ifreturn_tuples:
+ return[(key,value)for(key,value)inself._store.items()ifnotkey.startswith("_")]
+ return[keyforkeyinself._storeifnotstr(key).startswith("_")]
+"""
+This implements the common managers that are used by the
+abstract models in dbobjects.py (and which are thus shared by
+all Attributes and TypedObjects).
+
+"""
+importshlex
+fromdjango.db.modelsimportF,Q,Count,ExpressionWrapper,FloatField
+fromdjango.db.models.functionsimportCast
+fromevennia.utilsimportidmapper
+fromevennia.utils.utilsimportmake_iter,variable_from_module
+fromevennia.typeclasses.attributesimportAttribute
+fromevennia.typeclasses.tagsimportTag
+
+__all__=("TypedObjectManager",)
+_GA=object.__getattribute__
+_Tag=None
+
+
+# Managers
+
+
+
[docs]classTypedObjectManager(idmapper.manager.SharedMemoryManager):
+ """
+ Common ObjectManager for all dbobjects.
+
+ """
+
+ # common methods for all typed managers. These are used
+ # in other methods. Returns querysets.
+
+ # Attribute manager methods
+
[docs]defget_attribute(
+ self,key=None,category=None,value=None,strvalue=None,obj=None,attrtype=None,**kwargs
+ ):
+ """
+ Return Attribute objects by key, by category, by value, by
+ `strvalue`, by object (it is stored on) or with a combination of
+ those criteria.
+
+ Args:
+ key (str, optional): The attribute's key to search for.
+ category (str, optional): The category of the attribute(s)
+ to search for.
+ value (str, optional): The attribute value to search for.
+ Note that this is not a very efficient operation since it
+ will query for a pickled entity. Mutually exclusive to
+ `strvalue`.
+ strvalue (str, optional): The str-value to search for.
+ Most Attributes will not have strvalue set. This is
+ mutually exclusive to the `value` keyword and will take
+ precedence if given.
+ obj (Object, optional): On which object the Attribute to
+ search for is.
+ attrtype (str, optional): An attribute-type to search for.
+ By default this is either `None` (normal Attributes) or
+ `"nick"`.
+ kwargs (any): Currently unused. Reserved for future use.
+
+ Returns:
+ attributes (list): The matching Attributes.
+
+ """
+ dbmodel=self.model.__dbclass__.__name__.lower()
+ query=[("attribute__db_attrtype",attrtype),("attribute__db_model",dbmodel)]
+ ifobj:
+ query.append(("%s__id"%self.model.__dbclass__.__name__.lower(),obj.id))
+ ifkey:
+ query.append(("attribute__db_key",key))
+ ifcategory:
+ query.append(("attribute__db_category",category))
+ ifstrvalue:
+ query.append(("attribute__db_strvalue",strvalue))
+ ifvalue:
+ # no reason to make strvalue/value mutually exclusive at this level
+ query.append(("attribute__db_value",value))
+ returnAttribute.objects.filter(
+ pk__in=self.model.db_attributes.through.objects.filter(**dict(query)).values_list(
+ "attribute_id",flat=True
+ )
+ )
+
+
[docs]defget_nick(self,key=None,category=None,value=None,strvalue=None,obj=None):
+ """
+ Get a nick, in parallel to `get_attribute`.
+
+ Args:
+ key (str, optional): The nicks's key to search for
+ category (str, optional): The category of the nicks(s) to search for.
+ value (str, optional): The attribute value to search for. Note that this
+ is not a very efficient operation since it will query for a pickled
+ entity. Mutually exclusive to `strvalue`.
+ strvalue (str, optional): The str-value to search for. Most Attributes
+ will not have strvalue set. This is mutually exclusive to the `value`
+ keyword and will take precedence if given.
+ obj (Object, optional): On which object the Attribute to search for is.
+
+ Returns:
+ nicks (list): The matching Nicks.
+
+ """
+ returnself.get_attribute(
+ key=key,category=category,value=value,strvalue=strvalue,obj=obj
+ )
+
+
[docs]defget_by_attribute(
+ self,key=None,category=None,value=None,strvalue=None,attrtype=None,**kwargs
+ ):
+ """
+ Return objects having attributes with the given key, category,
+ value, strvalue or combination of those criteria.
+
+ Args:
+ key (str, optional): The attribute's key to search for
+ category (str, optional): The category of the attribute
+ to search for.
+ value (str, optional): The attribute value to search for.
+ Note that this is not a very efficient operation since it
+ will query for a pickled entity. Mutually exclusive to
+ `strvalue`.
+ strvalue (str, optional): The str-value to search for.
+ Most Attributes will not have strvalue set. This is
+ mutually exclusive to the `value` keyword and will take
+ precedence if given.
+ attrype (str, optional): An attribute-type to search for.
+ By default this is either `None` (normal Attributes) or
+ `"nick"`.
+ kwargs (any): Currently unused. Reserved for future use.
+
+ Returns:
+ obj (list): Objects having the matching Attributes.
+
+ """
+ dbmodel=self.model.__dbclass__.__name__.lower()
+ query=[
+ ("db_attributes__db_attrtype",attrtype),
+ ("db_attributes__db_model",dbmodel),
+ ]
+ ifkey:
+ query.append(("db_attributes__db_key",key))
+ ifcategory:
+ query.append(("db_attributes__db_category",category))
+ ifstrvalue:
+ query.append(("db_attributes__db_strvalue",strvalue))
+ elifvalue:
+ # strvalue and value are mutually exclusive
+ query.append(("db_attributes__db_value",value))
+ returnself.filter(**dict(query))
+
+
[docs]defget_by_nick(self,key=None,nick=None,category="inputline"):
+ """
+ Get object based on its key or nick.
+
+ Args:
+ key (str, optional): The attribute's key to search for
+ nick (str, optional): The nickname to search for
+ category (str, optional): The category of the nick
+ to search for.
+
+ Returns:
+ obj (list): Objects having the matching Nicks.
+
+ """
+ returnself.get_by_attribute(key=key,category=category,strvalue=nick,attrtype="nick")
+
+ # Tag manager methods
+
+
[docs]defget_tag(self,key=None,category=None,obj=None,tagtype=None,global_search=False):
+ """
+ Return Tag objects by key, by category, by object (it is
+ stored on) or with a combination of those criteria.
+
+ Args:
+ key (str, optional): The Tag's key to search for
+ category (str, optional): The Tag of the attribute(s)
+ to search for.
+ obj (Object, optional): On which object the Tag to
+ search for is.
+ tagtype (str, optional): One of None (normal tags),
+ "alias" or "permission"
+ global_search (bool, optional): Include all possible tags,
+ not just tags on this object
+
+ Returns:
+ tag (list): The matching Tags.
+
+ """
+ global_Tag
+ ifnot_Tag:
+ fromevennia.typeclasses.modelsimportTagas_Tag
+ dbmodel=self.model.__dbclass__.__name__.lower()
+ ifglobal_search:
+ # search all tags using the Tag model
+ query=[("db_tagtype",tagtype),("db_model",dbmodel)]
+ ifobj:
+ query.append(("id",obj.id))
+ ifkey:
+ query.append(("db_key",key))
+ ifcategory:
+ query.append(("db_category",category))
+ return_Tag.objects.filter(**dict(query))
+ else:
+ # search only among tags stored on on this model
+ query=[("tag__db_tagtype",tagtype),("tag__db_model",dbmodel)]
+ ifobj:
+ query.append(("%s__id"%self.model.__name__.lower(),obj.id))
+ ifkey:
+ query.append(("tag__db_key",key))
+ ifcategory:
+ query.append(("tag__db_category",category))
+ returnTag.objects.filter(
+ pk__in=self.model.db_tags.through.objects.filter(**dict(query)).values_list(
+ "tag_id",flat=True
+ )
+ )
+
+
[docs]defget_permission(self,key=None,category=None,obj=None):
+ """
+ Get a permission from the database.
+
+ Args:
+ key (str, optional): The permission's identifier.
+ category (str, optional): The permission's category.
+ obj (object, optional): The object on which this Tag is set.
+
+ Returns:
+ permission (list): Permission objects.
+
+ """
+ returnself.get_tag(key=key,category=category,obj=obj,tagtype="permission")
+
+
[docs]defget_alias(self,key=None,category=None,obj=None):
+ """
+ Get an alias from the database.
+
+ Args:
+ key (str, optional): The permission's identifier.
+ category (str, optional): The permission's category.
+ obj (object, optional): The object on which this Tag is set.
+
+ Returns:
+ alias (list): Alias objects.
+
+ """
+ returnself.get_tag(key=key,category=category,obj=obj,tagtype="alias")
+
+
[docs]defget_by_tag(self,key=None,category=None,tagtype=None,**kwargs):
+ """
+ Return objects having tags with a given key or category or combination of the two.
+ Also accepts multiple tags/category/tagtype
+
+ Args:
+ key (str or list, optional): Tag key or list of keys. Not case sensitive.
+ category (str or list, optional): Tag category. Not case sensitive.
+ If `key` is a list, a single category can either apply to all
+ keys in that list or this must be a list matching the `key`
+ list element by element. If no `key` is given, all objects with
+ tags of this category are returned.
+ tagtype (str, optional): 'type' of Tag, by default
+ this is either `None` (a normal Tag), `alias` or
+ `permission`. This always apply to all queried tags.
+
+ Keyword Args:
+ match (str): "all" (default) or "any"; determines whether the
+ target object must be tagged with ALL of the provided
+ tags/categories or ANY single one. ANY will perform a weighted
+ sort, so objects with more tag matches will outrank those with
+ fewer tag matches.
+
+ Returns:
+ objects (list): Objects with matching tag.
+
+ Raises:
+ IndexError: If `key` and `category` are both lists and `category` is shorter
+ than `key`.
+
+ """
+ ifnot(keyorcategory):
+ return[]
+
+ global_Tag
+ ifnot_Tag:
+ fromevennia.typeclasses.modelsimportTagas_Tag
+
+ anymatch="any"==kwargs.get("match","all").lower().strip()
+
+ keys=make_iter(key)ifkeyelse[]
+ categories=make_iter(category)ifcategoryelse[]
+ n_keys=len(keys)
+ n_categories=len(categories)
+ unique_categories=sorted(set(categories))
+ n_unique_categories=len(unique_categories)
+
+ dbmodel=self.model.__dbclass__.__name__.lower()
+ query=(
+ self.filter(db_tags__db_tagtype__iexact=tagtype,db_tags__db_model__iexact=dbmodel)
+ .distinct()
+ .order_by("id")
+ )
+
+ ifn_keys>0:
+ # keys and/or categories given
+ ifn_categories==0:
+ categories=[Nonefor_inrange(n_keys)]
+ elifn_categories==1andn_keys>1:
+ cat=categories[0]
+ categories=[catfor_inrange(n_keys)]
+ elif1<n_categories<n_keys:
+ raiseIndexError(
+ "get_by_tag needs a single category or a list of categories "
+ "the same length as the list of tags."
+ )
+ clauses=Q()
+ forikey,keyinenumerate(keys):
+ # ANY mode; must match any one of the given tags/categories
+ clauses|=Q(db_key__iexact=key,db_category__iexact=categories[ikey])
+ else:
+ # only one or more categories given
+ clauses=Q()
+ # ANY mode; must match any one of them
+ forcategoryinunique_categories:
+ clauses|=Q(db_category__iexact=category)
+
+ tags=_Tag.objects.filter(clauses)
+ query=query.filter(db_tags__in=tags).annotate(
+ matches=Count("db_tags__pk",filter=Q(db_tags__in=tags),distinct=True)
+ )
+
+ ifanymatch:
+ # ANY: Match any single tag, ordered by weight
+ query=query.order_by("-matches")
+ else:
+ # Default ALL: Match all of the tags and optionally more
+ n_req_tags=n_keysifn_keys>0elsen_unique_categories
+ query=query.filter(matches__gte=n_req_tags)
+
+ returnquery
+
+
[docs]defget_by_permission(self,key=None,category=None):
+ """
+ Return objects having permissions with a given key or category or
+ combination of the two.
+
+ Args:
+ key (str, optional): Permissions key. Not case sensitive.
+ category (str, optional): Permission category. Not case sensitive.
+ Returns:
+ objects (list): Objects with matching permission.
+ """
+ returnself.get_by_tag(key=key,category=category,tagtype="permission")
+
+
[docs]defget_by_alias(self,key=None,category=None):
+ """
+ Return objects having aliases with a given key or category or
+ combination of the two.
+
+ Args:
+ key (str, optional): Alias key. Not case sensitive.
+ category (str, optional): Alias category. Not case sensitive.
+ Returns:
+ objects (list): Objects with matching alias.
+ """
+ returnself.get_by_tag(key=key,category=category,tagtype="alias")
+
+
[docs]defcreate_tag(self,key=None,category=None,data=None,tagtype=None):
+ """
+ Create a new Tag of the base type associated with this
+ object. This makes sure to create case-insensitive tags.
+ If the exact same tag configuration (key+category+tagtype+dbmodel)
+ exists on the model, a new tag will not be created, but an old
+ one returned.
+
+
+ Args:
+ key (str, optional): Tag key. Not case sensitive.
+ category (str, optional): Tag category. Not case sensitive.
+ data (str, optional): Extra information about the tag.
+ tagtype (str or None, optional): 'type' of Tag, by default
+ this is either `None` (a normal Tag), `alias` or
+ `permission`.
+ Notes:
+ The `data` field is not part of the uniqueness of the tag:
+ Setting `data` on an existing tag will overwrite the old
+ data field. It is intended only as a way to carry
+ information about the tag (like a help text), not to carry
+ any information about the tagged objects themselves.
+
+ """
+ data=str(data)ifdataisnotNoneelseNone
+ # try to get old tag
+
+ dbmodel=self.model.__dbclass__.__name__.lower()
+ tag=self.get_tag(key=key,category=category,tagtype=tagtype,global_search=True)
+ iftaganddataisnotNone:
+ # get tag from list returned by get_tag
+ tag=tag[0]
+ # overload data on tag
+ tag.db_data=data
+ tag.save()
+ elifnottag:
+ # create a new tag
+ global_Tag
+ ifnot_Tag:
+ fromevennia.typeclasses.modelsimportTagas_Tag
+ tag=_Tag.objects.create(
+ db_key=key.strip().lower()ifkeyisnotNoneelseNone,
+ db_category=category.strip().lower()ifcategoryandkeyisnotNoneelseNone,
+ db_data=data,
+ db_model=dbmodel,
+ db_tagtype=tagtype.strip().lower()iftagtypeisnotNoneelseNone,
+ )
+ tag.save()
+ returnmake_iter(tag)[0]
+
+
[docs]defdbref(self,dbref,reqhash=True):
+ """
+ Determing if input is a valid dbref.
+
+ Args:
+ dbref (str or int): A possible dbref.
+ reqhash (bool, optional): If the "#" is required for this
+ to be considered a valid hash.
+
+ Returns:
+ dbref (int or None): The integer part of the dbref.
+
+ Notes:
+ Valid forms of dbref (database reference number) are
+ either a string '#N' or an integer N.
+
+ """
+ ifreqhashandnot(isinstance(dbref,str)anddbref.startswith("#")):
+ returnNone
+ ifisinstance(dbref,str):
+ dbref=dbref.lstrip("#")
+ try:
+ ifint(dbref)<0:
+ returnNone
+ exceptException:
+ returnNone
+ returndbref
+
+
[docs]defget_id(self,dbref):
+ """
+ Find object with given dbref.
+
+ Args:
+ dbref (str or int): The id to search for.
+
+ Returns:
+ object (TypedObject): The matched object.
+
+ """
+ dbref=self.dbref(dbref,reqhash=False)
+ try:
+ returnself.get(id=dbref)
+ exceptself.model.DoesNotExist:
+ pass
+ returnNone
+
+
[docs]defdbref_search(self,dbref):
+ """
+ Alias to get_id.
+
+ Args:
+ dbref (str or int): The id to search for.
+
+ Returns:
+ object (TypedObject): The matched object.
+
+ """
+ returnself.get_id(dbref)
+
+
[docs]defget_dbref_range(self,min_dbref=None,max_dbref=None):
+ """
+ Get objects within a certain range of dbrefs.
+
+ Args:
+ min_dbref (int): Start of dbref range.
+ max_dbref (int): End of dbref range (inclusive)
+
+ Returns:
+ objects (list): TypedObjects with dbrefs within
+ the given dbref ranges.
+
+ """
+ retval=super().all()
+ ifmin_dbrefisnotNone:
+ retval=retval.filter(id__gte=self.dbref(min_dbref,reqhash=False))
+ ifmax_dbrefisnotNone:
+ retval=retval.filter(id__lte=self.dbref(max_dbref,reqhash=False))
+ returnretval
+
+
[docs]defget_typeclass_totals(self,*args,**kwargs)->object:
+ """
+ Returns a queryset of typeclass composition statistics.
+
+ Returns:
+ qs (Queryset): A queryset of dicts containing the typeclass (name),
+ the count of objects with that typeclass and a float representing
+ the percentage of objects associated with the typeclass.
+
+ """
+ return(
+ self.values("db_typeclass_path")
+ .distinct()
+ .annotate(
+ # Get count of how many objects for each typeclass exist
+ count=Count("db_typeclass_path")
+ )
+ .annotate(
+ # Rename db_typeclass_path field to something more human
+ typeclass=F("db_typeclass_path"),
+ # Calculate this class' percentage of total composition
+ percent=ExpressionWrapper(
+ ((F("count")/float(self.count()))*100.0),output_field=FloatField(),
+ ),
+ )
+ .values("typeclass","count","percent")
+ )
+
+
[docs]defobject_totals(self):
+ """
+ Get info about database statistics.
+
+ Returns:
+ census (dict): A dictionary `{typeclass_path: number, ...}` with
+ all the typeclasses active in-game as well as the number
+ of such objects defined (i.e. the number of database
+ object having that typeclass set on themselves).
+
+ """
+ stats=self.get_typeclass_totals().order_by("typeclass")
+ return{x.get("typeclass"):x.get("count")forxinstats}
+
+
[docs]deftypeclass_search(self,typeclass,include_children=False,include_parents=False):
+ """
+ Searches through all objects returning those which has a
+ certain typeclass. If location is set, limit search to objects
+ in that location.
+
+ Args:
+ typeclass (str or class): A typeclass class or a python path to a typeclass.
+ include_children (bool, optional): Return objects with
+ given typeclass *and* all children inheriting from this
+ typeclass. Mutuall exclusive to `include_parents`.
+ include_parents (bool, optional): Return objects with
+ given typeclass *and* all parents to this typeclass.
+ Mutually exclusive to `include_children`.
+
+ Returns:
+ objects (list): The objects found with the given typeclasses.
+
+ """
+
+ ifcallable(typeclass):
+ cls=typeclass.__class__
+ typeclass="%s.%s"%(cls.__module__,cls.__name__)
+ elifnotisinstance(typeclass,str)andhasattr(typeclass,"path"):
+ typeclass=typeclass.path
+
+ # query objects of exact typeclass
+ query=Q(db_typeclass_path__exact=typeclass)
+
+ ifinclude_children:
+ # build requests for child typeclass objects
+ clsmodule,clsname=typeclass.rsplit(".",1)
+ cls=variable_from_module(clsmodule,clsname)
+ subclasses=cls.__subclasses__()
+ ifsubclasses:
+ forchildin(childforchildinsubclassesifhasattr(child,"path")):
+ query=query|Q(db_typeclass_path__exact=child.path)
+ elifinclude_parents:
+ # build requests for parent typeclass objects
+ clsmodule,clsname=typeclass.rsplit(".",1)
+ cls=variable_from_module(clsmodule,clsname)
+ parents=cls.__mro__
+ ifparents:
+ forparentin(parentforparentinparentsifhasattr(parent,"path")):
+ query=query|Q(db_typeclass_path__exact=parent.path)
+ # actually query the database
+ returnself.filter(query)
+
+
+classTypeclassManager(TypedObjectManager):
+ """
+ Manager for the typeclasses. The main purpose of this manager is
+ to limit database queries to the given typeclass despite all
+ typeclasses technically being defined in the same core database
+ model.
+
+ """
+
+ # object-manager methods
+ defsmart_search(self,query):
+ """
+ Search by supplying a string with optional extra search criteria to aid the query.
+
+ Args:
+ query (str): A search criteria that accepts extra search criteria on the
+
+ following forms: [key|alias|#dbref...] [tag==<tagstr>[:category]...] [attr==<key>:<value>:category...]
+ " != " != "
+ Returns:
+ matches (queryset): A queryset result matching all queries exactly. If wanting to use spaces or
+ ==, != in tags or attributes, enclose them in quotes.
+
+ Note:
+ The flexibility of this method is limited by the input line format. Tag/attribute
+ matching only works for matching primitives. For even more complex queries, such as
+ 'in' operations or object field matching, use the full django query language.
+
+ """
+ # shlex splits by spaces unless escaped by quotes
+ querysplit=shlex.split(query)
+ queries,plustags,plusattrs,negtags,negattrs=[],[],[],[],[]
+ foripart,partinenumerate(querysplit):
+ key,rest=part,""
+ if":"inpart:
+ key,rest=part.split(":",1)
+ # tags are on the form tag or tag:category
+ ifkey.startswith("tag=="):
+ plustags.append((key[5:],rest))
+ continue
+ elifkey.startswith("tag!="):
+ negtags.append((key[5:],rest))
+ continue
+ # attrs are on the form attr:value or attr:value:category
+ elifrest:
+ value,category=rest,""
+ if":"inrest:
+ value,category=rest.split(":",1)
+ ifkey.startswith("attr=="):
+ plusattrs.append((key[7:],value,category))
+ continue
+ elifkey.startswith("attr!="):
+ negattrs.append((key[7:],value,category))
+ continue
+ # if we get here, we are entering a key search criterion which
+ # we assume is one word.
+ queries.append(part)
+ # build query from components
+ query=" ".join(queries)
+ # TODO
+
+ defget(self,*args,**kwargs):
+ """
+ Overload the standard get. This will limit itself to only
+ return the current typeclass.
+
+ Args:
+ args (any): These are passed on as arguments to the default
+ django get method.
+ Keyword Args:
+ kwargs (any): These are passed on as normal arguments
+ to the default django get method
+ Returns:
+ object (object): The object found.
+
+ Raises:
+ ObjectNotFound: The exact name of this exception depends
+ on the model base used.
+
+ """
+ kwargs.update({"db_typeclass_path":self.model.path})
+ returnsuper().get(**kwargs)
+
+ deffilter(self,*args,**kwargs):
+ """
+ Overload of the standard filter function. This filter will
+ limit itself to only the current typeclass.
+
+ Args:
+ args (any): These are passed on as arguments to the default
+ django filter method.
+ Keyword Args:
+ kwargs (any): These are passed on as normal arguments
+ to the default django filter method.
+ Returns:
+ objects (queryset): The objects found.
+
+ """
+ kwargs.update({"db_typeclass_path":self.model.path})
+ returnsuper().filter(*args,**kwargs)
+
+ defall(self):
+ """
+ Overload method to return all matches, filtering for typeclass.
+
+ Returns:
+ objects (queryset): The objects found.
+
+ """
+ returnsuper().all().filter(db_typeclass_path=self.model.path)
+
+ deffirst(self):
+ """
+ Overload method to return first match, filtering for typeclass.
+
+ Returns:
+ object (object): The object found.
+
+ Raises:
+ ObjectNotFound: The exact name of this exception depends
+ on the model base used.
+
+ """
+ returnsuper().filter(db_typeclass_path=self.model.path).first()
+
+ deflast(self):
+ """
+ Overload method to return last match, filtering for typeclass.
+
+ Returns:
+ object (object): The object found.
+
+ Raises:
+ ObjectNotFound: The exact name of this exception depends
+ on the model base used.
+
+ """
+ returnsuper().filter(db_typeclass_path=self.model.path).last()
+
+ defcount(self):
+ """
+ Overload method to return number of matches, filtering for typeclass.
+
+ Returns:
+ integer : Number of objects found.
+
+ """
+ returnsuper().filter(db_typeclass_path=self.model.path).count()
+
+ defannotate(self,*args,**kwargs):
+ """
+ Overload annotate method to filter on typeclass before annotating.
+ Args:
+ *args (any): Positional arguments passed along to queryset annotate method.
+ **kwargs (any): Keyword arguments passed along to queryset annotate method.
+
+ Returns:
+ Annotated queryset.
+ """
+ return(
+ super(TypeclassManager,self)
+ .filter(db_typeclass_path=self.model.path)
+ .annotate(*args,**kwargs)
+ )
+
+ defvalues(self,*args,**kwargs):
+ """
+ Overload values method to filter on typeclass first.
+ Args:
+ *args (any): Positional arguments passed along to values method.
+ **kwargs (any): Keyword arguments passed along to values method.
+
+ Returns:
+ Queryset of values dictionaries, just filtered by typeclass first.
+ """
+ return(
+ super(TypeclassManager,self)
+ .filter(db_typeclass_path=self.model.path)
+ .values(*args,**kwargs)
+ )
+
+ defvalues_list(self,*args,**kwargs):
+ """
+ Overload values method to filter on typeclass first.
+ Args:
+ *args (any): Positional arguments passed along to values_list method.
+ **kwargs (any): Keyword arguments passed along to values_list method.
+
+ Returns:
+ Queryset of value_list tuples, just filtered by typeclass first.
+ """
+ return(
+ super(TypeclassManager,self)
+ .filter(db_typeclass_path=self.model.path)
+ .values_list(*args,**kwargs)
+ )
+
+ def_get_subclasses(self,cls):
+ """
+ Recursively get all subclasses to a class.
+
+ Args:
+ cls (classoject): A class to get subclasses from.
+ """
+ all_subclasses=cls.__subclasses__()
+ forsubclassinall_subclasses:
+ all_subclasses.extend(self._get_subclasses(subclass))
+ returnall_subclasses
+
+ defget_family(self,**kwargs):
+ """
+ Variation of get that not only returns the current typeclass
+ but also all subclasses of that typeclass.
+
+ Keyword Args:
+ kwargs (any): These are passed on as normal arguments
+ to the default django get method.
+ Returns:
+ objects (list): The objects found.
+
+ Raises:
+ ObjectNotFound: The exact name of this exception depends
+ on the model base used.
+
+ """
+ paths=[self.model.path]+[
+ "%s.%s"%(cls.__module__,cls.__name__)forclsinself._get_subclasses(self.model)
+ ]
+ kwargs.update({"db_typeclass_path__in":paths})
+ returnsuper().get(**kwargs)
+
+ deffilter_family(self,*args,**kwargs):
+ """
+ Variation of filter that allows results both from typeclass
+ and from subclasses of typeclass
+
+ Args:
+ args (any): These are passed on as arguments to the default
+ django filter method.
+ Keyword Args:
+ kwargs (any): These are passed on as normal arguments
+ to the default django filter method.
+ Returns:
+ objects (list): The objects found.
+
+ """
+ # query, including all subclasses
+ paths=[self.model.path]+[
+ "%s.%s"%(cls.__module__,cls.__name__)forclsinself._get_subclasses(self.model)
+ ]
+ kwargs.update({"db_typeclass_path__in":paths})
+ returnsuper().filter(*args,**kwargs)
+
+ defall_family(self):
+ """
+ Return all matches, allowing matches from all subclasses of
+ the typeclass.
+
+ Returns:
+ objects (list): The objects found.
+
+ """
+ paths=[self.model.path]+[
+ "%s.%s"%(cls.__module__,cls.__name__)forclsinself._get_subclasses(self.model)
+ ]
+ returnsuper().all().filter(db_typeclass_path__in=paths)
+
+"""
+This is the *abstract* django models for many of the database objects
+in Evennia. A django abstract (obs, not the same as a Python metaclass!) is
+a model which is not actually created in the database, but which only exists
+for other models to inherit from, to avoid code duplication. Any model can
+import and inherit from these classes.
+
+Attributes are database objects stored on other objects. The implementing
+class needs to supply a ForeignKey field attr_object pointing to the kind
+of object being mapped. Attributes storing iterables actually store special
+types of iterables named PackedList/PackedDict respectively. These make
+sure to save changes to them to database - this is criticial in order to
+allow for obj.db.mylist[2] = data. Also, all dbobjects are saved as
+dbrefs but are also aggressively cached.
+
+TypedObjects are objects 'decorated' with a typeclass - that is, the typeclass
+(which is a normal Python class implementing some special tricks with its
+get/set attribute methods, allows for the creation of all sorts of different
+objects all with the same database object underneath. Usually attributes are
+used to permanently store things not hard-coded as field on the database object.
+The admin should usually not have to deal directly with the database object
+layer.
+
+This module also contains the Managers for the respective models; inherit from
+these to create custom managers.
+
+----
+
+"""
+fromdjango.db.modelsimportsignals
+
+fromdjango.db.models.baseimportModelBase
+fromdjango.dbimportmodels
+fromdjango.contrib.contenttypes.modelsimportContentType
+fromdjango.core.exceptionsimportObjectDoesNotExist
+fromdjango.confimportsettings
+fromdjango.urlsimportreverse
+fromdjango.utils.encodingimportsmart_str
+fromdjango.utils.textimportslugify
+
+fromevennia.typeclasses.attributesimportAttribute,AttributeHandler,NAttributeHandler
+fromevennia.typeclasses.tagsimportTag,TagHandler,AliasHandler,PermissionHandler
+
+fromevennia.utils.idmapper.modelsimportSharedMemoryModel,SharedMemoryModelBase
+fromevennia.server.signalsimportSIGNAL_TYPED_OBJECT_POST_RENAME
+
+fromevennia.typeclassesimportmanagers
+fromevennia.locks.lockhandlerimportLockHandler
+fromevennia.utils.utilsimportis_iter,inherits_from,lazy_property,class_from_module
+fromevennia.utils.loggerimportlog_trace
+
+__all__=("TypedObject",)
+
+TICKER_HANDLER=None
+
+_PERMISSION_HIERARCHY=[p.lower()forpinsettings.PERMISSION_HIERARCHY]
+_TYPECLASS_AGGRESSIVE_CACHE=settings.TYPECLASS_AGGRESSIVE_CACHE
+_GA=object.__getattribute__
+_SA=object.__setattr__
+
+
+# signal receivers. Connected in __new__
+
+
+defcall_at_first_save(sender,instance,created,**kwargs):
+ """
+ Receives a signal just after the object is saved.
+ """
+ ifcreated:
+ instance.at_first_save()
+
+
+defremove_attributes_on_delete(sender,instance,**kwargs):
+ """
+ Wipe object's Attributes when it's deleted
+ """
+ instance.db_attributes.all().delete()
+
+
+# ------------------------------------------------------------
+#
+# Typed Objects
+#
+# ------------------------------------------------------------
+
+
+#
+# Meta class for typeclasses
+#
+
+
+classTypeclassBase(SharedMemoryModelBase):
+ """
+ Metaclass which should be set for the root of model proxies
+ that don't define any new fields, like Object, Script etc. This
+ is the basis for the typeclassing system.
+ """
+
+ def__new__(cls,name,bases,attrs):
+ """
+ We must define our Typeclasses as proxies. We also store the
+ path directly on the class, this is required by managers.
+ """
+
+ # storage of stats
+ attrs["typename"]=name
+ attrs["path"]="%s.%s"%(attrs["__module__"],name)
+
+ def_get_dbmodel(bases):
+ """Recursively get the dbmodel"""
+ ifnothasattr(bases,"__iter__"):
+ bases=[bases]
+ forbaseinbases:
+ try:
+ ifbase._meta.proxyorbase._meta.abstract:
+ forklsinbase._meta.parents:
+ return_get_dbmodel(kls)
+ exceptAttributeError:
+ # this happens if trying to parse a non-typeclass mixin parent,
+ # without a _meta
+ continue
+ else:
+ returnbase
+ returnNone
+
+ dbmodel=_get_dbmodel(bases)
+
+ ifnotdbmodel:
+ raiseTypeError(f"{name} does not appear to inherit from a database model.")
+
+ # typeclass proxy setup
+ # first check explicit __applabel__ on the typeclass, then figure
+ # it out from the dbmodel
+ if"__applabel__"notinattrs:
+ # find the app-label in one of the bases, usually the dbmodel
+ attrs["__applabel__"]=dbmodel._meta.app_label
+
+ if"Meta"notinattrs:
+
+ classMeta:
+ proxy=True
+ app_label=attrs.get("__applabel__","typeclasses")
+
+ attrs["Meta"]=Meta
+ attrs["Meta"].proxy=True
+
+ new_class=ModelBase.__new__(cls,name,bases,attrs)
+
+ # django doesn't support inheriting proxy models so we hack support for
+ # it here by injecting `proxy_for_model` to the actual dbmodel.
+ # Unfortunately we cannot also set the correct model_name, because this
+ # would block multiple-inheritance of typeclasses (Django doesn't allow
+ # multiple bases of the same model).
+ ifdbmodel:
+ new_class._meta.proxy_for_model=dbmodel
+ # Maybe Django will eventually handle this in the future:
+ # new_class._meta.model_name = dbmodel._meta.model_name
+
+ # attach signals
+ signals.post_save.connect(call_at_first_save,sender=new_class)
+ signals.pre_delete.connect(remove_attributes_on_delete,sender=new_class)
+ returnnew_class
+
+
+classDbHolder(object):
+ """
+ Holder for allowing property access of attributes.
+
+ """
+
+ def__init__(self,obj,name,manager_name="attributes"):
+ _SA(self,name,_GA(obj,manager_name))
+ _SA(self,"name",name)
+
+ def__getattribute__(self,attrname):
+ ifattrname=="all":
+ # we allow to overload our default .all
+ attr=_GA(self,_GA(self,"name")).get("all")
+ returnattrifattrelse_GA(self,"all")
+ return_GA(self,_GA(self,"name")).get(attrname)
+
+ def__setattr__(self,attrname,value):
+ _GA(self,_GA(self,"name")).add(attrname,value)
+
+ def__delattr__(self,attrname):
+ _GA(self,_GA(self,"name")).remove(attrname)
+
+ defget_all(self):
+ return_GA(self,_GA(self,"name")).all()
+
+ all=property(get_all)
+
+
+#
+# Main TypedObject abstraction
+#
+
+
+
[docs]classTypedObject(SharedMemoryModel):
+ """
+ Abstract Django model.
+
+ This is the basis for a typed object. It also contains all the
+ mechanics for managing connected attributes.
+
+ The TypedObject has the following properties:
+ key - main name
+ name - alias for key
+ typeclass_path - the path to the decorating typeclass
+ typeclass - auto-linked typeclass
+ date_created - time stamp of object creation
+ permissions - perm strings
+ dbref - #id of object
+ db - persistent attribute storage
+ ndb - non-persistent attribute storage
+
+ """
+
+ #
+ # TypedObject Database Model setup
+ #
+ #
+ # These databse fields are all accessed and set using their corresponding
+ # properties, named same as the field, but without the db_* prefix
+ # (no separate save() call is needed)
+
+ # Main identifier of the object, for searching. Is accessed with self.key
+ # or self.name
+ db_key=models.CharField("key",max_length=255,db_index=True)
+ # This is the python path to the type class this object is tied to. The
+ # typeclass is what defines what kind of Object this is)
+ db_typeclass_path=models.CharField(
+ "typeclass",
+ max_length=255,
+ null=True,
+ help_text="this defines what 'type' of entity this is. This variable holds a Python path to a module with a valid Evennia Typeclass.",
+ db_index=True,
+ )
+ # Creation date. This is not changed once the object is created.
+ db_date_created=models.DateTimeField("creation date",editable=False,auto_now_add=True)
+ # Lock storage
+ db_lock_storage=models.TextField(
+ "locks",
+ blank=True,
+ help_text="locks limit access to an entity. A lock is defined as a 'lock string' on the form 'type:lockfunctions', defining what functionality is locked and how to determine access. Not defining a lock means no access is granted.",
+ )
+ # many2many relationships
+ db_attributes=models.ManyToManyField(
+ Attribute,
+ help_text="attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).",
+ )
+ db_tags=models.ManyToManyField(
+ Tag,
+ help_text="tags on this object. Tags are simple string markers to identify, group and alias objects.",
+ )
+
+ # Database manager
+ objects=managers.TypedObjectManager()
+
+ # quick on-object typeclass cache for speed
+ _cached_typeclass=None
+
+ # typeclass mechanism
+
+
[docs]defset_class_from_typeclass(self,typeclass_path=None):
+ iftypeclass_path:
+ try:
+ self.__class__=class_from_module(
+ typeclass_path,defaultpaths=settings.TYPECLASS_PATHS
+ )
+ exceptException:
+ log_trace()
+ try:
+ self.__class__=class_from_module(self.__settingsclasspath__)
+ exceptException:
+ log_trace()
+ try:
+ self.__class__=class_from_module(self.__defaultclasspath__)
+ exceptException:
+ log_trace()
+ self.__class__=self._meta.concrete_modelorself.__class__
+ finally:
+ self.db_typeclass_path=typeclass_path
+ elifself.db_typeclass_path:
+ try:
+ self.__class__=class_from_module(self.db_typeclass_path)
+ exceptException:
+ log_trace()
+ try:
+ self.__class__=class_from_module(self.__defaultclasspath__)
+ exceptException:
+ log_trace()
+ self.__dbclass__=self._meta.concrete_modelorself.__class__
+ else:
+ self.db_typeclass_path="%s.%s"%(self.__module__,self.__class__.__name__)
+ # important to put this at the end since _meta is based on the set __class__
+ try:
+ self.__dbclass__=self._meta.concrete_modelorself.__class__
+ exceptAttributeError:
+ err_class=repr(self.__class__)
+ self.__class__=class_from_module("evennia.objects.objects.DefaultObject")
+ self.__dbclass__=class_from_module("evennia.objects.models.ObjectDB")
+ self.db_typeclass_path="evennia.objects.objects.DefaultObject"
+ log_trace(
+ "Critical: Class %s of %s is not a valid typeclass!\nTemporarily falling back to %s."
+ %(err_class,self,self.__class__)
+ )
+
+
[docs]def__init__(self,*args,**kwargs):
+ """
+ The `__init__` method of typeclasses is the core operational
+ code of the typeclass system, where it dynamically re-applies
+ a class based on the db_typeclass_path database field rather
+ than use the one in the model.
+
+ Args:
+ *args: Passed through to parent.
+ **kwargs: Passed through to parent.
+
+ Notes:
+ The loading mechanism will attempt the following steps:
+
+ 1. Attempt to load typeclass given on command line
+ 2. Attempt to load typeclass stored in db_typeclass_path
+ 3. Attempt to load `__settingsclasspath__`, which is by the
+ default classes defined to be the respective user-set
+ base typeclass settings, like `BASE_OBJECT_TYPECLASS`.
+ 4. Attempt to load `__defaultclasspath__`, which is the
+ base classes in the library, like DefaultObject etc.
+ 5. If everything else fails, use the database model.
+
+ Normal operation is to load successfully at either step 1
+ or 2 depending on how the class was called. Tracebacks
+ will be logged for every step the loader must take beyond
+ 2.
+
+ """
+ typeclass_path=kwargs.pop("typeclass",None)
+ super().__init__(*args,**kwargs)
+ self.set_class_from_typeclass(typeclass_path=typeclass_path)
+
+ # wrapper
+ # Wrapper properties to easily set database fields. These are
+ # @property decorators that allows to access these fields using
+ # normal python operations (without having to remember to save()
+ # etc). So e.g. a property 'attr' has a get/set/del decorator
+ # defined that allows the user to do self.attr = value,
+ # value = self.attr and del self.attr respectively (where self
+ # is the object in question).
+
+ # name property (alias to self.key)
+ def__name_get(self):
+ returnself.key
+
+ def__name_set(self,value):
+ self.key=value
+
+ def__name_del(self):
+ raiseException("Cannot delete name")
+
+ name=property(__name_get,__name_set,__name_del)
+
+ # key property (overrides's the idmapper's db_key for the at_rename hook)
+ @property
+ defkey(self):
+ returnself.db_key
+
+ @key.setter
+ defkey(self,value):
+ oldname=str(self.db_key)
+ self.db_key=value
+ self.save(update_fields=["db_key"])
+ self.at_rename(oldname,value)
+ SIGNAL_TYPED_OBJECT_POST_RENAME.send(sender=self,old_key=oldname,new_key=value)
+
+ #
+ #
+ # TypedObject main class methods and properties
+ #
+ #
+
+ def__eq__(self,other):
+ try:
+ returnself.__dbclass__==other.__dbclass__andself.dbid==other.dbid
+ exceptAttributeError:
+ returnFalse
+
+ def__hash__(self):
+ # this is required to maintain hashing
+ returnsuper().__hash__()
+
+ def__str__(self):
+ returnsmart_str("%s"%self.db_key)
+
+ def__repr__(self):
+ return"%s"%self.db_key
+
+ # @property
+ def__dbid_get(self):
+ """
+ Caches and returns the unique id of the object.
+ Use this instead of self.id, which is not cached.
+ """
+ returnself.id
+
+ def__dbid_set(self,value):
+ raiseException("dbid cannot be set!")
+
+ def__dbid_del(self):
+ raiseException("dbid cannot be deleted!")
+
+ dbid=property(__dbid_get,__dbid_set,__dbid_del)
+
+ # @property
+ def__dbref_get(self):
+ """
+ Returns the object's dbref on the form #NN.
+ """
+ return"#%s"%self.id
+
+ def__dbref_set(self):
+ raiseException("dbref cannot be set!")
+
+ def__dbref_del(self):
+ raiseException("dbref cannot be deleted!")
+
+ dbref=property(__dbref_get,__dbref_set,__dbref_del)
+
+
[docs]defat_idmapper_flush(self):
+ """
+ This is called when the idmapper cache is flushed and
+ allows customized actions when this happens.
+
+ Returns:
+ do_flush (bool): If True, flush this object as normal. If
+ False, don't flush and expect this object to handle
+ the flushing on its own.
+
+ Notes:
+ The default implementation relies on being able to clear
+ Django's Foreignkey cache on objects not affected by the
+ flush (notably objects with an NAttribute stored). We rely
+ on this cache being stored on the format "_<fieldname>_cache".
+ If Django were to change this name internally, we need to
+ update here (unlikely, but marking just in case).
+
+ """
+ ifself.nattributes.all():
+ # we can't flush this object if we have non-persistent
+ # attributes stored - those would get lost! Nevertheless
+ # we try to flush as many references as we can.
+ self.attributes.reset_cache()
+ self.tags.reset_cache()
+ # flush caches for all related fields
+ forfieldinself._meta.fields:
+ name="_%s_cache"%field.name
+ iffield.is_relationandnameinself.__dict__:
+ # a foreignkey - remove its cache
+ delself.__dict__[name]
+ returnFalse
+ # a normal flush
+ returnTrue
+
+ #
+ # Object manipulation methods
+ #
+
+
[docs]defis_typeclass(self,typeclass,exact=False):
+ """
+ Returns true if this object has this type OR has a typeclass
+ which is an subclass of the given typeclass. This operates on
+ the actually loaded typeclass (this is important since a
+ failing typeclass may instead have its default currently
+ loaded) typeclass - can be a class object or the python path
+ to such an object to match against.
+
+ Args:
+ typeclass (str or class): A class or the full python path
+ to the class to check.
+ exact (bool, optional): Returns true only if the object's
+ type is exactly this typeclass, ignoring parents.
+
+ Returns:
+ is_typeclass (bool): If this typeclass matches the given
+ typeclass.
+
+ """
+ ifisinstance(typeclass,str):
+ typeclass=[typeclass]+[
+ "%s.%s"%(prefix,typeclass)forprefixinsettings.TYPECLASS_PATHS
+ ]
+ else:
+ typeclass=[typeclass.path]
+
+ selfpath=self.path
+ ifexact:
+ # check only exact match
+ returnselfpathintypeclass
+ else:
+ # check parent chain
+ returnany(
+ hasattr(cls,"path")andcls.pathintypeclassforclsinself.__class__.mro()
+ )
+
+
[docs]defswap_typeclass(
+ self,
+ new_typeclass,
+ clean_attributes=False,
+ run_start_hooks="all",
+ no_default=True,
+ clean_cmdsets=False,
+ ):
+ """
+ This performs an in-situ swap of the typeclass. This means
+ that in-game, this object will suddenly be something else.
+ Account will not be affected. To 'move' an account to a different
+ object entirely (while retaining this object's type), use
+ self.account.swap_object().
+
+ Note that this might be an error prone operation if the
+ old/new typeclass was heavily customized - your code
+ might expect one and not the other, so be careful to
+ bug test your code if using this feature! Often its easiest
+ to create a new object and just swap the account over to
+ that one instead.
+
+ Args:
+ new_typeclass (str or classobj): Type to switch to.
+ clean_attributes (bool or list, optional): Will delete all
+ attributes stored on this object (but not any of the
+ database fields such as name or location). You can't get
+ attributes back, but this is often the safest bet to make
+ sure nothing in the new typeclass clashes with the old
+ one. If you supply a list, only those named attributes
+ will be cleared.
+ run_start_hooks (str or None, optional): This is either None,
+ to not run any hooks, "all" to run all hooks defined by
+ at_first_start, or a string giving the name of the hook
+ to run (for example 'at_object_creation'). This will
+ always be called without arguments.
+ no_default (bool, optiona): If set, the swapper will not
+ allow for swapping to a default typeclass in case the
+ given one fails for some reason. Instead the old one will
+ be preserved.
+ clean_cmdsets (bool, optional): Delete all cmdsets on the object.
+
+ """
+
+ ifnotcallable(new_typeclass):
+ # this is an actual class object - build the path
+ new_typeclass=class_from_module(new_typeclass,defaultpaths=settings.TYPECLASS_PATHS)
+
+ # if we get to this point, the class is ok.
+
+ ifinherits_from(self,"evennia.scripts.models.ScriptDB"):
+ ifself.interval>0:
+ raiseRuntimeError(
+ "Cannot use swap_typeclass on time-dependent "
+ "Script '%s'.\nStop and start a new Script of the "
+ "right type instead."%self.key
+ )
+
+ self.typeclass_path=new_typeclass.path
+ self.__class__=new_typeclass
+
+ ifclean_attributes:
+ # Clean out old attributes
+ ifis_iter(clean_attributes):
+ forattrinclean_attributes:
+ self.attributes.remove(attr)
+ fornattrinclean_attributes:
+ ifhasattr(self.ndb,nattr):
+ self.nattributes.remove(nattr)
+ else:
+ self.attributes.clear()
+ self.nattributes.clear()
+ ifclean_cmdsets:
+ # purge all cmdsets
+ self.cmdset.clear()
+ self.cmdset.remove_default()
+
+ ifrun_start_hooks=="all":
+ # fake this call to mimic the first save
+ self.at_first_save()
+ elifrun_start_hooks:
+ # a custom hook-name to call.
+ getattr(self,run_start_hooks)()
+
+ #
+ # Lock / permission methods
+ #
+
+
[docs]defaccess(
+ self,accessing_obj,access_type="read",default=False,no_superuser_bypass=False,**kwargs
+ ):
+ """
+ Determines if another object has permission to access this one.
+
+ Args:
+ accessing_obj (str): Object trying to access this one.
+ access_type (str, optional): Type of access sought.
+ default (bool, optional): What to return if no lock of
+ access_type was found
+ no_superuser_bypass (bool, optional): Turn off the
+ superuser lock bypass (be careful with this one).
+
+ Keyword Args:
+ kwargs (any): Ignored, but is there to make the api
+ consistent with the object-typeclass method access, which
+ use it to feed to its hook methods.
+
+ """
+ returnself.locks.check(
+ accessing_obj,
+ access_type=access_type,
+ default=default,
+ no_superuser_bypass=no_superuser_bypass,
+ )
+
+
[docs]defcheck_permstring(self,permstring):
+ """
+ This explicitly checks if we hold particular permission
+ without involving any locks.
+
+ Args:
+ permstring (str): The permission string to check against.
+
+ Returns:
+ result (bool): If the permstring is passed or not.
+
+ """
+ ifhasattr(self,"account"):
+ if(
+ self.account
+ andself.account.is_superuser
+ andnotself.account.attributes.get("_quell")
+ ):
+ returnTrue
+ else:
+ ifself.is_superuserandnotself.attributes.get("_quell"):
+ returnTrue
+
+ ifnotpermstring:
+ returnFalse
+ perm=permstring.lower()
+ perms=[p.lower()forpinself.permissions.all()]
+ ifperminperms:
+ # simplest case - we have a direct match
+ returnTrue
+ ifpermin_PERMISSION_HIERARCHY:
+ # check if we have a higher hierarchy position
+ ppos=_PERMISSION_HIERARCHY.index(perm)
+ returnany(
+ True
+ forhpos,hperminenumerate(_PERMISSION_HIERARCHY)
+ ifhperminpermsandhpos>ppos
+ )
+ # we ignore pluralization (english only)
+ ifperm.endswith("s"):
+ returnself.check_permstring(perm[:-1])
+
+ returnFalse
+
+ #
+ # Attribute storage
+ #
+
+ # @property db
+ def__db_get(self):
+ """
+ Attribute handler wrapper. Allows for the syntax
+ ::
+
+ obj.db.attrname = value
+ and
+ value = obj.db.attrname
+ and
+ del obj.db.attrname
+ and
+ all_attr = obj.db.all()
+
+ (unless there is an attribute named 'all', in which case that will be
+ returned instead).
+
+ """
+ try:
+ returnself._db_holder
+ exceptAttributeError:
+ self._db_holder=DbHolder(self,"attributes")
+ returnself._db_holder
+
+ # @db.setter
+ def__db_set(self,value):
+ """Stop accidentally replacing the db object"""
+ string="Cannot assign directly to db object! "
+ string+="Use db.attr=value instead."
+ raiseException(string)
+
+ # @db.deleter
+ def__db_del(self):
+ """Stop accidental deletion."""
+ raiseException("Cannot delete the db object!")
+
+ db=property(__db_get,__db_set,__db_del)
+
+ #
+ # Non-persistent (ndb) storage
+ #
+
+ # @property ndb
+ def__ndb_get(self):
+ """
+ A non-attr_obj store (NonDataBase). Everything stored to this is
+ guaranteed to be cleared when a server is shutdown. Syntax is same as
+ for the `.db` property, e.g.
+ ::
+
+ obj.ndb.attrname = value
+ and
+ value = obj.ndb.attrname
+ and
+ del obj.ndb.attrname
+ and
+ all_attr = obj.ndb.all()
+
+ What makes this preferable over just assigning properties directly on
+ the object is that Evennia can track caching for these properties and
+ for example avoid wiping objects with set `.ndb` data on cache flushes.
+
+ """
+ try:
+ returnself._ndb_holder
+ exceptAttributeError:
+ self._ndb_holder=DbHolder(self,"nattrhandler",manager_name="nattributes")
+ returnself._ndb_holder
+
+ # @db.setter
+ def__ndb_set(self,value):
+ "Stop accidentally replacing the ndb object"
+ string="Cannot assign directly to ndb object! "
+ string+="Use ndb.attr=value instead."
+ raiseException(string)
+
+ # @db.deleter
+ def__ndb_del(self):
+ "Stop accidental deletion."
+ raiseException("Cannot delete the ndb object!")
+
+ ndb=property(__ndb_get,__ndb_set,__ndb_del)
+
+
[docs]defget_display_name(self,looker,**kwargs):
+ """
+ Displays the name of the object in a viewer-aware manner.
+
+ Args:
+ looker (TypedObject, optional): The object or account that is looking
+ at/getting inforamtion for this object. If not given, some
+ 'safe' minimum level should be returned.
+
+ Returns:
+ name (str): A string containing the name of the object,
+ including the DBREF if this user is privileged to control
+ said object.
+
+ Notes:
+ This function could be extended to change how object names
+ appear to users in character, but be wary. This function
+ does not change an object's keys or aliases when
+ searching, and is expected to produce something useful for
+ builders.
+
+ """
+ ifself.access(looker,access_type="controls"):
+ return"{}(#{})".format(self.name,self.id)
+ returnself.name
+
+
[docs]defget_extra_info(self,looker,**kwargs):
+ """
+ Used when an object is in a list of ambiguous objects as an
+ additional information tag.
+
+ For instance, if you had potions which could have varying
+ levels of liquid left in them, you might want to display how
+ many drinks are left in each when selecting which to drop, but
+ not in your normal inventory listing.
+
+ Args:
+ looker (TypedObject): The object or account that is looking
+ at/getting information for this object.
+
+ Returns:
+ info (str): A string with disambiguating information,
+ conventionally with a leading space.
+
+ """
+
+ ifself.location==looker:
+ return" (carried)"
+ return""
+
+
[docs]defat_rename(self,oldname,newname):
+ """
+ This Hook is called by @name on a successful rename.
+
+ Args:
+ oldname (str): The instance's original name.
+ newname (str): The new name for the instance.
+
+ """
+ pass
+
+ #
+ # Web/Django methods
+ #
+
+
[docs]defweb_get_admin_url(self):
+ """
+ Returns the URI path for the Django Admin page for this object.
+
+ ex. Account#1 = '/admin/accounts/accountdb/1/change/'
+
+ Returns:
+ path (str): URI path to Django Admin page for object.
+
+ """
+ content_type=ContentType.objects.get_for_model(self.__class__)
+ returnreverse(
+ "admin:%s_%s_change"%(content_type.app_label,content_type.model),args=(self.id,)
+ )
+
+
[docs]@classmethod
+ defweb_get_create_url(cls):
+ """
+
+ Returns the URI path for a View that allows users to create new
+ instances of this object.
+
+ Returns:
+ path (str): URI path to object creation page, if defined.
+
+ Examples:
+ ::
+
+ Chargen = '/characters/create/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'character-create' would be referenced by this method.
+ ::
+
+ url(r'characters/create/', ChargenView.as_view(), name='character-create')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ Notes:
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can create new objects is the
+ developer's responsibility.
+
+ """
+ try:
+ returnreverse("%s-create"%slugify(cls._meta.verbose_name))
+ except:
+ return"#"
+
+
[docs]defweb_get_detail_url(self):
+ """
+ Returns the URI path for a View that allows users to view details for
+ this object.
+
+ Returns:
+ path (str): URI path to object detail page, if defined.
+
+ Examples:
+ ::
+
+ Oscar (Character) = '/characters/oscar/1/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'character-detail' would be referenced by this method.
+ ::
+
+ CharDetailView.as_view(), name='character-detail')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ Notes:
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can view this object is the
+ developer's responsibility.
+
+ """
+ try:
+ returnreverse(
+ "%s-detail"%slugify(self._meta.verbose_name),
+ kwargs={"pk":self.pk,"slug":slugify(self.name)},
+ )
+ except:
+ return"#"
+
+
[docs]defweb_get_puppet_url(self):
+ """
+ Returns the URI path for a View that allows users to puppet a specific
+ object.
+
+ Returns:
+ path (str): URI path to object puppet page, if defined.
+
+ Examples:
+ ::
+
+ Oscar (Character) = '/characters/oscar/1/puppet/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'character-puppet' would be referenced by this method.
+ ::
+
+ url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/puppet/$',
+ CharPuppetView.as_view(), name='character-puppet')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ Notes:
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can view this object is the developer's
+ responsibility.
+
+
+ """
+ try:
+ returnreverse(
+ "%s-puppet"%slugify(self._meta.verbose_name),
+ kwargs={"pk":self.pk,"slug":slugify(self.name)},
+ )
+ except:
+ return"#"
+
+
[docs]defweb_get_update_url(self):
+ """
+ Returns the URI path for a View that allows users to update this
+ object.
+
+ Returns:
+ path (str): URI path to object update page, if defined.
+
+ Examples:
+ ::
+
+ Oscar (Character) = '/characters/oscar/1/change/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'character-update' would be referenced by this method.
+ ::
+
+ url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
+ CharUpdateView.as_view(), name='character-update')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ Notes:
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can modify objects is the developer's
+ responsibility.
+
+ """
+ try:
+ returnreverse(
+ "%s-update"%slugify(self._meta.verbose_name),
+ kwargs={"pk":self.pk,"slug":slugify(self.name)},
+ )
+ except:
+ return"#"
+
+
[docs]defweb_get_delete_url(self):
+ """
+ Returns the URI path for a View that allows users to delete this object.
+
+ Returns:
+ path (str): URI path to object deletion page, if defined.
+
+ Examples:
+ ::
+
+ Oscar (Character) = '/characters/oscar/1/delete/'
+
+ For this to work, the developer must have defined a named view somewhere
+ in urls.py that follows the format 'modelname-action', so in this case
+ a named view of 'character-detail' would be referenced by this method.
+ ::
+
+ url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
+ CharDeleteView.as_view(), name='character-delete')
+
+ If no View has been created and defined in urls.py, returns an
+ HTML anchor.
+
+ Notes:
+ This method is naive and simply returns a path. Securing access to
+ the actual view and limiting who can delete this object is the developer's
+ responsibility.
+
+
+ """
+ try:
+ returnreverse(
+ "%s-delete"%slugify(self._meta.verbose_name),
+ kwargs={"pk":self.pk,"slug":slugify(self.name)},
+ )
+ except:
+ return"#"
+
+ # Used by Django Sites/Admin
+ get_absolute_url=web_get_detail_url
+"""
+Tags are entities that are attached to objects in the same way as
+Attributes. But contrary to Attributes, which are unique to an
+individual object, a single Tag can be attached to any number of
+objects at the same time.
+
+Tags are used for tagging, obviously, but the data structure is also
+used for storing Aliases and Permissions. This module contains the
+respective handlers.
+
+"""
+fromcollectionsimportdefaultdict
+
+fromdjango.confimportsettings
+fromdjango.dbimportmodels
+fromevennia.utils.utilsimportto_str,make_iter
+
+
+_TYPECLASS_AGGRESSIVE_CACHE=settings.TYPECLASS_AGGRESSIVE_CACHE
+
+# ------------------------------------------------------------
+#
+# Tags
+#
+# ------------------------------------------------------------
+
+
+
[docs]classTag(models.Model):
+ """
+ Tags are quick markers for objects in-game. An typeobject can have
+ any number of tags, stored via its db_tags property. Tagging
+ similar objects will make it easier to quickly locate the group
+ later (such as when implementing zones). The main advantage of
+ tagging as opposed to using tags is speed; a tag is very
+ limited in what data it can hold, and the tag key+category is
+ indexed for efficient lookup in the database. Tags are shared
+ between objects - a new tag is only created if the key+category
+ combination did not previously exist, making them unsuitable for
+ storing object-related data (for this a regular Attribute should be
+ used).
+
+ The 'db_data' field is intended as a documentation field for the
+ tag itself, such as to document what this tag+category stands for
+ and display that in a web interface or similar.
+
+ The main default use for Tags is to implement Aliases for objects.
+ this uses the 'aliases' tag category, which is also checked by the
+ default search functions of Evennia to allow quick searches by alias.
+
+ """
+
+ db_key=models.CharField(
+ "key",max_length=255,null=True,help_text="tag identifier",db_index=True
+ )
+ db_category=models.CharField(
+ "category",max_length=64,null=True,help_text="tag category",db_index=True
+ )
+ db_data=models.TextField(
+ "data",
+ null=True,
+ blank=True,
+ help_text="optional data field with extra information. This is not searched for.",
+ )
+ # this is "objectdb" etc. Required behind the scenes
+ db_model=models.CharField(
+ "model",max_length=32,null=True,help_text="database model to Tag",db_index=True
+ )
+ # this is None, alias or permission
+ db_tagtype=models.CharField(
+ "tagtype",
+ max_length=16,
+ null=True,
+ blank=True,
+ help_text="overall type of Tag",
+ db_index=True,
+ )
+
+ classMeta(object):
+ "Define Django meta options"
+ verbose_name="Tag"
+ unique_together=(("db_key","db_category","db_tagtype","db_model"),)
+ index_together=(("db_key","db_category","db_tagtype","db_model"),)
+
+ def__lt__(self,other):
+ returnstr(self)<str(other)
+
+ def__str__(self):
+ returnstr(
+ "<Tag: %s%s>"
+ %(self.db_key,"(category:%s)"%self.db_categoryifself.db_categoryelse"")
+ )
+
+
+#
+# Handlers making use of the Tags model
+#
+
+
+
[docs]def__init__(self,obj):
+ """
+ Tags are stored internally in the TypedObject.db_tags m2m
+ field with an tag.db_model based on the obj the taghandler is
+ stored on and with a tagtype given by self.handlertype
+
+ Args:
+ obj (object): The object on which the handler is set.
+
+ """
+ self.obj=obj
+ self._objid=obj.id
+ self._model=obj.__dbclass__.__name__.lower()
+ self._cache={}
+ # store category names fully cached
+ self._catcache={}
+ # full cache was run on all tags
+ self._cache_complete=False
+
+ def_query_all(self):
+ "Get all tags for this objects"
+ query={
+ "%s__id"%self._model:self._objid,
+ "tag__db_model":self._model,
+ "tag__db_tagtype":self._tagtype,
+ }
+ return[
+ conn.tag
+ forconningetattr(self.obj,self._m2m_fieldname).through.objects.filter(**query)
+ ]
+
+ def_fullcache(self):
+ "Cache all tags of this object"
+ ifnot_TYPECLASS_AGGRESSIVE_CACHE:
+ return
+ tags=self._query_all()
+ self._cache=dict(
+ (
+ "%s-%s"
+ %(
+ to_str(tag.db_key).lower(),
+ tag.db_category.lower()iftag.db_categoryelseNone,
+ ),
+ tag,
+ )
+ fortagintags
+ )
+ self._cache_complete=True
+
+ def_getcache(self,key=None,category=None):
+ """
+ Retrieve from cache or database (always caches)
+
+ Args:
+ key (str, optional): Tag key to query for
+ category (str, optional): Tag category
+
+ Returns:
+ args (list): Returns a list of zero or more matches
+ found from cache or database.
+ Notes:
+ When given a category only, a search for all objects
+ of that category is done and a the category *name* is is
+ stored. This tells the system on subsequent calls that the
+ list of cached tags of this category is up-to-date
+ and that the cache can be queried for category matches
+ without missing any.
+ The TYPECLASS_AGGRESSIVE_CACHE=False setting will turn off
+ caching, causing each tag access to trigger a
+ database lookup.
+
+ """
+ key=key.strip().lower()ifkeyelseNone
+ category=category.strip().lower()ifcategoryelseNone
+ ifkey:
+ cachekey="%s-%s"%(key,category)
+ tag=_TYPECLASS_AGGRESSIVE_CACHEandself._cache.get(cachekey,None)
+ iftagand(nothasattr(tag,"pk")andtag.pkisNone):
+ # clear out Tags deleted from elsewhere. We must search this anew.
+ tag=None
+ delself._cache[cachekey]
+ iftag:
+ return[tag]# return cached entity
+ else:
+ query={
+ "%s__id"%self._model:self._objid,
+ "tag__db_model":self._model,
+ "tag__db_tagtype":self._tagtype,
+ "tag__db_key__iexact":key.lower(),
+ "tag__db_category__iexact":category.lower()ifcategoryelseNone,
+ }
+ conn=getattr(self.obj,self._m2m_fieldname).through.objects.filter(**query)
+ ifconn:
+ tag=conn[0].tag
+ if_TYPECLASS_AGGRESSIVE_CACHE:
+ self._cache[cachekey]=tag
+ return[tag]
+ else:
+ # only category given (even if it's None) - we can't
+ # assume the cache to be complete unless we have queried
+ # for this category before
+ catkey="-%s"%category
+ if_TYPECLASS_AGGRESSIVE_CACHEandcatkeyinself._catcache:
+ return[tagforkey,taginself._cache.items()ifkey.endswith(catkey)]
+ else:
+ # we have to query to make this category up-date in the cache
+ query={
+ "%s__id"%self._model:self._objid,
+ "tag__db_model":self._model,
+ "tag__db_tagtype":self._tagtype,
+ "tag__db_category__iexact":category.lower()ifcategoryelseNone,
+ }
+ tags=[
+ conn.tag
+ forconningetattr(self.obj,self._m2m_fieldname).through.objects.filter(
+ **query
+ )
+ ]
+ if_TYPECLASS_AGGRESSIVE_CACHE:
+ fortagintags:
+ cachekey="%s-%s"%(tag.db_key,category)
+ self._cache[cachekey]=tag
+ # mark category cache as up-to-date
+ self._catcache[catkey]=True
+ returntags
+ return[]
+
+ def_setcache(self,key,category,tag_obj):
+ """
+ Update cache.
+
+ Args:
+ key (str): A cleaned key string
+ category (str or None): A cleaned category name
+ tag_obj (tag): The newly saved tag
+
+ """
+ ifnot_TYPECLASS_AGGRESSIVE_CACHE:
+ return
+ ifnotkey:# don't allow an empty key in cache
+ return
+ key,category=(key.strip().lower(),category.strip().lower()ifcategoryelsecategory)
+ cachekey="%s-%s"%(key,category)
+ catkey="-%s"%category
+ self._cache[cachekey]=tag_obj
+ # mark that the category cache is no longer up-to-date
+ self._catcache.pop(catkey,None)
+ self._cache_complete=False
+
+ def_delcache(self,key,category):
+ """
+ Remove tag from cache
+
+ Args:
+ key (str): A cleaned key string
+ category (str or None): A cleaned category name
+
+ """
+ key,category=(key.strip().lower(),category.strip().lower()ifcategoryelsecategory)
+ catkey="-%s"%category
+ ifkey:
+ cachekey="%s-%s"%(key,category)
+ self._cache.pop(cachekey,None)
+ else:
+ [self._cache.pop(key,None)forkeyinself._cacheifkey.endswith(catkey)]
+ # mark that the category cache is no longer up-to-date
+ self._catcache.pop(catkey,None)
+ self._cache_complete=False
+
+
[docs]defreset_cache(self):
+ """
+ Reset the cache from the outside.
+ """
+ self._cache_complete=False
+ self._cache={}
+ self._catcache={}
+
+
[docs]defadd(self,tag=None,category=None,data=None):
+ """
+ Add a new tag to the handler.
+
+ Args:
+ tag (str or list): The name of the tag to add. If a list,
+ add several Tags.
+ category (str, optional): Category of Tag. `None` is the default category.
+ data (str, optional): Info text about the tag(s) added.
+ This can not be used to store object-unique info but only
+ eventual info about the tag itself.
+
+ Notes:
+ If the tag + category combination matches an already
+ existing Tag object, this will be re-used and no new Tag
+ will be created.
+
+ """
+ ifnottag:
+ return
+ ifnotself._cache_complete:
+ self._fullcache()
+ fortagstrinmake_iter(tag):
+ ifnottagstr:
+ continue
+ tagstr=str(tagstr).strip().lower()
+ category=str(category).strip().lower()ifcategoryelsecategory
+ data=str(data)ifdataisnotNoneelseNone
+ # this will only create tag if no matches existed beforehand (it
+ # will overload data on an existing tag since that is not
+ # considered part of making the tag unique)
+ tagobj=self.obj.__class__.objects.create_tag(
+ key=tagstr,category=category,data=data,tagtype=self._tagtype
+ )
+ getattr(self.obj,self._m2m_fieldname).add(tagobj)
+ self._setcache(tagstr,category,tagobj)
+
+
[docs]defget(self,key=None,default=None,category=None,return_tagobj=False,return_list=False):
+ """
+ Get the tag for the given key, category or combination of the two.
+
+ Args:
+ key (str or list, optional): The tag or tags to retrieve.
+ default (any, optional): The value to return in case of no match.
+ category (str, optional): The Tag category to limit the
+ request to. Note that `None` is the valid, default
+ category. If no `key` is given, all tags of this category will be
+ returned.
+ return_tagobj (bool, optional): Return the Tag object itself
+ instead of a string representation of the Tag.
+ return_list (bool, optional): Always return a list, regardless
+ of number of matches.
+
+ Returns:
+ tags (list): The matches, either string
+ representations of the tags or the Tag objects themselves
+ depending on `return_tagobj`. If 'default' is set, this
+ will be a list with the default value as its only element.
+
+ """
+ ret=[]
+ forkeystrinmake_iter(key):
+ # note - the _getcache call removes case sensitivity for us
+ ret.extend(
+ [
+ tagifreturn_tagobjelseto_str(tag.db_key)
+ fortaginself._getcache(keystr,category)
+ ]
+ )
+ ifreturn_list:
+ returnretifretelse[default]ifdefaultisnotNoneelse[]
+ returnret[0]iflen(ret)==1else(retifretelsedefault)
+
+
[docs]defremove(self,key=None,category=None):
+ """
+ Remove a tag from the handler based ond key and/or category.
+
+ Args:
+ key (str or list, optional): The tag or tags to retrieve.
+ category (str, optional): The Tag category to limit the
+ request to. Note that `None` is the valid, default
+ category
+ Notes:
+ If neither key nor category is specified, this acts
+ as .clear().
+
+ """
+ ifnotkey:
+ # only category
+ self.clear(category=category)
+ return
+
+ forkeyinmake_iter(key):
+ ifnot(keyorkey.strip()):# we don't allow empty tags
+ continue
+ tagstr=key.strip().lower()
+ category=category.strip().lower()ifcategoryelsecategory
+
+ # This does not delete the tag object itself. Maybe it should do
+ # that when no objects reference the tag anymore (but how to check)?
+ # For now, tags are never deleted, only their connection to objects.
+ tagobj=getattr(self.obj,self._m2m_fieldname).filter(
+ db_key=tagstr,db_category=category,db_model=self._model,db_tagtype=self._tagtype
+ )
+ iftagobj:
+ getattr(self.obj,self._m2m_fieldname).remove(tagobj[0])
+ self._delcache(key,category)
+
+
[docs]defclear(self,category=None):
+ """
+ Remove all tags from the handler.
+
+ Args:
+ category (str, optional): The Tag category to limit the
+ request to. Note that `None` is the valid, default
+ category.
+
+ """
+ ifnotself._cache_complete:
+ self._fullcache()
+ query={
+ "%s__id"%self._model:self._objid,
+ "tag__db_model":self._model,
+ "tag__db_tagtype":self._tagtype,
+ }
+ ifcategory:
+ query["tag__db_category"]=category.strip().lower()
+ getattr(self.obj,self._m2m_fieldname).through.objects.filter(**query).delete()
+ self._cache={}
+ self._catcache={}
+ self._cache_complete=False
+
+
[docs]defall(self,return_key_and_category=False,return_objs=False):
+ """
+ Get all tags in this handler, regardless of category.
+
+ Args:
+ return_key_and_category (bool, optional): Return a list of
+ tuples `[(key, category), ...]`.
+ return_objs (bool, optional): Return tag objects.
+
+ Returns:
+ tags (list): A list of tag keys `[tagkey, tagkey, ...]` or
+ a list of tuples `[(key, category), ...]` if
+ `return_key_and_category` is set.
+
+ """
+ if_TYPECLASS_AGGRESSIVE_CACHE:
+ ifnotself._cache_complete:
+ self._fullcache()
+ tags=sorted(self._cache.values())
+ else:
+ tags=sorted(self._query_all())
+
+ ifreturn_key_and_category:
+ # return tuple (key, category)
+ return[(to_str(tag.db_key),tag.db_category)fortagintags]
+ elifreturn_objs:
+ returntags
+ else:
+ return[to_str(tag.db_key)fortagintags]
+
+
[docs]defbatch_add(self,*args):
+ """
+ Batch-add tags from a list of tuples.
+
+ Args:
+ *args (tuple or str): Each argument should be a `tagstr` keys or tuple `(keystr, category)` or
+ `(keystr, category, data)`. It's possible to mix input types.
+
+ Notes:
+ This will generate a mimimal number of self.add calls,
+ based on the number of categories involved (including
+ `None`) (data is not unique and may be overwritten by the content
+ of a latter tuple with the same category).
+
+ """
+ keys=defaultdict(list)
+ data={}
+ fortupinargs:
+ tup=make_iter(tup)
+ nlen=len(tup)
+ ifnlen==1:# just a key
+ keys[None].append(tup[0])
+ elifnlen==2:
+ keys[tup[1]].append(tup[0])
+ else:
+ keys[tup[1]].append(tup[0])
+ data[tup[1]]=tup[2]# overwrite previous
+ forcategory,keyinkeys.items():
+ self.add(tag=key,category=category,data=data.get(category,None))
+"""
+ANSI - Gives colour to text.
+
+Use the codes defined in ANSIPARSER in your text to apply colour to text
+according to the ANSI standard.
+
+Examples:
+
+```python
+"This is |rRed text|n and this is normal again."
+```
+
+Mostly you should not need to call `parse_ansi()` explicitly; it is run by
+Evennia just before returning data to/from the user. Depreciated example forms
+are available by extending the ansi mapping.
+
+"""
+importfunctools
+
+importre
+fromcollectionsimportOrderedDict
+
+fromdjango.confimportsettings
+
+fromevennia.utilsimportutils
+fromevennia.utilsimportlogger
+
+fromevennia.utils.utilsimportto_str
+
+
+# ANSI definitions
+
+ANSI_BEEP="\07"
+ANSI_ESCAPE="\033"
+ANSI_NORMAL="\033[0m"
+
+ANSI_UNDERLINE="\033[4m"
+ANSI_HILITE="\033[1m"
+ANSI_UNHILITE="\033[22m"
+ANSI_BLINK="\033[5m"
+ANSI_INVERSE="\033[7m"
+ANSI_INV_HILITE="\033[1;7m"
+ANSI_INV_BLINK="\033[7;5m"
+ANSI_BLINK_HILITE="\033[1;5m"
+ANSI_INV_BLINK_HILITE="\033[1;5;7m"
+
+# Foreground colors
+ANSI_BLACK="\033[30m"
+ANSI_RED="\033[31m"
+ANSI_GREEN="\033[32m"
+ANSI_YELLOW="\033[33m"
+ANSI_BLUE="\033[34m"
+ANSI_MAGENTA="\033[35m"
+ANSI_CYAN="\033[36m"
+ANSI_WHITE="\033[37m"
+
+# Background colors
+ANSI_BACK_BLACK="\033[40m"
+ANSI_BACK_RED="\033[41m"
+ANSI_BACK_GREEN="\033[42m"
+ANSI_BACK_YELLOW="\033[43m"
+ANSI_BACK_BLUE="\033[44m"
+ANSI_BACK_MAGENTA="\033[45m"
+ANSI_BACK_CYAN="\033[46m"
+ANSI_BACK_WHITE="\033[47m"
+
+# Formatting Characters
+ANSI_RETURN="\r\n"
+ANSI_TAB="\t"
+ANSI_SPACE=" "
+
+# Escapes
+ANSI_ESCAPES=("{{","\\\\","\|\|")
+
+_PARSE_CACHE=OrderedDict()
+_PARSE_CACHE_SIZE=10000
+
+_COLOR_NO_DEFAULT=settings.COLOR_NO_DEFAULT
+
+
+
[docs]classANSIParser(object):
+ """
+ A class that parses ANSI markup to ANSI command sequences.
+
+ We also allow to escape colour codes by prepending with an extra `|`.
+
+ """
+
+ # Mapping using {r {n etc
+
+ ansi_map=[
+ # alternative |-format
+ (r"|n",ANSI_NORMAL),# reset
+ (r"|/",ANSI_RETURN),# line break
+ (r"|-",ANSI_TAB),# tab
+ (r"|>",ANSI_SPACE*4),# indent (4 spaces)
+ (r"|_",ANSI_SPACE),# space
+ (r"|*",ANSI_INVERSE),# invert
+ (r"|^",ANSI_BLINK),# blinking text (very annoying and not supported by all clients)
+ (r"|u",ANSI_UNDERLINE),# underline
+ (r"|r",ANSI_HILITE+ANSI_RED),
+ (r"|g",ANSI_HILITE+ANSI_GREEN),
+ (r"|y",ANSI_HILITE+ANSI_YELLOW),
+ (r"|b",ANSI_HILITE+ANSI_BLUE),
+ (r"|m",ANSI_HILITE+ANSI_MAGENTA),
+ (r"|c",ANSI_HILITE+ANSI_CYAN),
+ (r"|w",ANSI_HILITE+ANSI_WHITE),# pure white
+ (r"|x",ANSI_HILITE+ANSI_BLACK),# dark grey
+ (r"|R",ANSI_UNHILITE+ANSI_RED),
+ (r"|G",ANSI_UNHILITE+ANSI_GREEN),
+ (r"|Y",ANSI_UNHILITE+ANSI_YELLOW),
+ (r"|B",ANSI_UNHILITE+ANSI_BLUE),
+ (r"|M",ANSI_UNHILITE+ANSI_MAGENTA),
+ (r"|C",ANSI_UNHILITE+ANSI_CYAN),
+ (r"|W",ANSI_UNHILITE+ANSI_WHITE),# light grey
+ (r"|X",ANSI_UNHILITE+ANSI_BLACK),# pure black
+ # hilight-able colors
+ (r"|h",ANSI_HILITE),
+ (r"|H",ANSI_UNHILITE),
+ (r"|!R",ANSI_RED),
+ (r"|!G",ANSI_GREEN),
+ (r"|!Y",ANSI_YELLOW),
+ (r"|!B",ANSI_BLUE),
+ (r"|!M",ANSI_MAGENTA),
+ (r"|!C",ANSI_CYAN),
+ (r"|!W",ANSI_WHITE),# light grey
+ (r"|!X",ANSI_BLACK),# pure black
+ # normal ANSI backgrounds
+ (r"|[R",ANSI_BACK_RED),
+ (r"|[G",ANSI_BACK_GREEN),
+ (r"|[Y",ANSI_BACK_YELLOW),
+ (r"|[B",ANSI_BACK_BLUE),
+ (r"|[M",ANSI_BACK_MAGENTA),
+ (r"|[C",ANSI_BACK_CYAN),
+ (r"|[W",ANSI_BACK_WHITE),# light grey background
+ (r"|[X",ANSI_BACK_BLACK),# pure black background
+ ]
+
+ ansi_xterm256_bright_bg_map=[
+ # "bright" ANSI backgrounds using xterm256 since ANSI
+ # standard does not support it (will
+ # fallback to dark ANSI background colors if xterm256
+ # is not supported by client)
+ # |-style variations
+ (r"|[r",r"|[500"),
+ (r"|[g",r"|[050"),
+ (r"|[y",r"|[550"),
+ (r"|[b",r"|[005"),
+ (r"|[m",r"|[505"),
+ (r"|[c",r"|[055"),
+ (r"|[w",r"|[555"),# white background
+ (r"|[x",r"|[222"),
+ ]# dark grey background
+
+ # xterm256. These are replaced directly by
+ # the sub_xterm256 method
+
+ ifsettings.COLOR_NO_DEFAULT:
+ ansi_map=settings.COLOR_ANSI_EXTRA_MAP
+ xterm256_fg=settings.COLOR_XTERM256_EXTRA_FG
+ xterm256_bg=settings.COLOR_XTERM256_EXTRA_BG
+ xterm256_gfg=settings.COLOR_XTERM256_EXTRA_GFG
+ xterm256_gbg=settings.COLOR_XTERM256_EXTRA_GBG
+ ansi_xterm256_bright_bg_map=settings.COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP
+ else:
+ xterm256_fg=[r"\|([0-5])([0-5])([0-5])"]# |123 - foreground colour
+ xterm256_bg=[r"\|\[([0-5])([0-5])([0-5])"]# |[123 - background colour
+ xterm256_gfg=[r"\|=([a-z])"]# |=a - greyscale foreground
+ xterm256_gbg=[r"\|\[=([a-z])"]# |[=a - greyscale background
+ ansi_map+=settings.COLOR_ANSI_EXTRA_MAP
+ xterm256_fg+=settings.COLOR_XTERM256_EXTRA_FG
+ xterm256_bg+=settings.COLOR_XTERM256_EXTRA_BG
+ xterm256_gfg+=settings.COLOR_XTERM256_EXTRA_GFG
+ xterm256_gbg+=settings.COLOR_XTERM256_EXTRA_GBG
+ ansi_xterm256_bright_bg_map+=settings.COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP
+
+ mxp_re=r"\|lc(.*?)\|lt(.*?)\|le"
+
+ # prepare regex matching
+ brightbg_sub=re.compile(
+ r"|".join([r"(?<!\|)%s"%re.escape(tup[0])fortupinansi_xterm256_bright_bg_map]),
+ re.DOTALL,
+ )
+ xterm256_fg_sub=re.compile(r"|".join(xterm256_fg),re.DOTALL)
+ xterm256_bg_sub=re.compile(r"|".join(xterm256_bg),re.DOTALL)
+ xterm256_gfg_sub=re.compile(r"|".join(xterm256_gfg),re.DOTALL)
+ xterm256_gbg_sub=re.compile(r"|".join(xterm256_gbg),re.DOTALL)
+
+ # xterm256_sub = re.compile(r"|".join([tup[0] for tup in xterm256_map]), re.DOTALL)
+ ansi_sub=re.compile(r"|".join([re.escape(tup[0])fortupinansi_map]),re.DOTALL)
+ mxp_sub=re.compile(mxp_re,re.DOTALL)
+
+ # used by regex replacer to correctly map ansi sequences
+ ansi_map_dict=dict(ansi_map)
+ ansi_xterm256_bright_bg_map_dict=dict(ansi_xterm256_bright_bg_map)
+
+ # prepare matching ansi codes overall
+ ansi_re=r"\033\[[0-9;]+m"
+ ansi_regex=re.compile(ansi_re)
+
+ # escapes - these double-chars will be replaced with a single
+ # instance of each
+ ansi_escapes=re.compile(r"(%s)"%"|".join(ANSI_ESCAPES),re.DOTALL)
+
+
[docs]defsub_ansi(self,ansimatch):
+ """
+ Replacer used by `re.sub` to replace ANSI
+ markers with correct ANSI sequences
+
+ Args:
+ ansimatch (re.matchobject): The match.
+
+ Returns:
+ processed (str): The processed match string.
+
+ """
+ returnself.ansi_map_dict.get(ansimatch.group(),"")
+
+
[docs]defsub_brightbg(self,ansimatch):
+ """
+ Replacer used by `re.sub` to replace ANSI
+ bright background markers with Xterm256 replacement
+
+ Args:
+ ansimatch (re.matchobject): The match.
+
+ Returns:
+ processed (str): The processed match string.
+
+ """
+ returnself.ansi_xterm256_bright_bg_map_dict.get(ansimatch.group(),"")
+
+
[docs]defsub_xterm256(self,rgbmatch,use_xterm256=False,color_type="fg"):
+ """
+ This is a replacer method called by `re.sub` with the matched
+ tag. It must return the correct ansi sequence.
+
+ It checks `self.do_xterm256` to determine if conversion
+ to standard ANSI should be done or not.
+
+ Args:
+ rgbmatch (re.matchobject): The match.
+ use_xterm256 (bool, optional): Don't convert 256-colors to 16.
+ color_type (str): One of 'fg', 'bg', 'gfg', 'gbg'.
+
+ Returns:
+ processed (str): The processed match string.
+
+ """
+ ifnotrgbmatch:
+ return""
+
+ # get tag, stripping the initial marker
+ # rgbtag = rgbmatch.group()[1:]
+
+ background=color_typein("bg","gbg")
+ grayscale=color_typein("gfg","gbg")
+
+ ifnotgrayscale:
+ # 6x6x6 color-cube (xterm indexes 16-231)
+ try:
+ red,green,blue=[int(val)forvalinrgbmatch.groups()ifvalisnotNone]
+ except(IndexError,ValueError):
+ logger.log_trace()
+ returnrgbmatch.group(0)
+ else:
+ # grayscale values (xterm indexes 0, 232-255, 15) for full spectrum
+ try:
+ letter=[valforvalinrgbmatch.groups()ifvalisnotNone][0]
+ exceptIndexError:
+ logger.log_trace()
+ returnrgbmatch.group(0)
+
+ ifletter=="a":
+ colval=16# pure black @ index 16 (first color cube entry)
+ elifletter=="z":
+ colval=231# pure white @ index 231 (last color cube entry)
+ else:
+ # letter in range [b..y] (exactly 24 values!)
+ colval=134+ord(letter)
+
+ # ansi fallback logic expects r,g,b values in [0..5] range
+ gray=(ord(letter)-97)/5.0
+ red,green,blue=gray,gray,gray
+
+ ifuse_xterm256:
+
+ ifnotgrayscale:
+ colval=16+(red*36)+(green*6)+blue
+
+ return"\033[%s8;5;%sm"%(3+int(background),colval)
+ # replaced since some clients (like Potato) does not accept codes with leading zeroes, see issue #1024.
+ # return "\033[%s8;5;%s%s%sm" % (3 + int(background), colval // 100, (colval % 100) // 10, colval%10)
+
+ else:
+ # xterm256 not supported, convert the rgb value to ansi instead
+ ifred==green==blueandred<3:
+ ifbackground:
+ returnANSI_BACK_BLACK
+ elifred>=1:
+ returnANSI_HILITE+ANSI_BLACK
+ else:
+ returnANSI_NORMAL+ANSI_BLACK
+ elifred==green==blue:
+ ifbackground:
+ returnANSI_BACK_WHITE
+ elifred>=4:
+ returnANSI_HILITE+ANSI_WHITE
+ else:
+ returnANSI_NORMAL+ANSI_WHITE
+ elifred>greenandred>blue:
+ ifbackground:
+ returnANSI_BACK_RED
+ elifred>=3:
+ returnANSI_HILITE+ANSI_RED
+ else:
+ returnANSI_NORMAL+ANSI_RED
+ elifred==greenandred>blue:
+ ifbackground:
+ returnANSI_BACK_YELLOW
+ elifred>=3:
+ returnANSI_HILITE+ANSI_YELLOW
+ else:
+ returnANSI_NORMAL+ANSI_YELLOW
+ elifred==blueandred>green:
+ ifbackground:
+ returnANSI_BACK_MAGENTA
+ elifred>=3:
+ returnANSI_HILITE+ANSI_MAGENTA
+ else:
+ returnANSI_NORMAL+ANSI_MAGENTA
+ elifgreen>blue:
+ ifbackground:
+ returnANSI_BACK_GREEN
+ elifgreen>=3:
+ returnANSI_HILITE+ANSI_GREEN
+ else:
+ returnANSI_NORMAL+ANSI_GREEN
+ elifgreen==blue:
+ ifbackground:
+ returnANSI_BACK_CYAN
+ elifgreen>=3:
+ returnANSI_HILITE+ANSI_CYAN
+ else:
+ returnANSI_NORMAL+ANSI_CYAN
+ else:# mostly blue
+ ifbackground:
+ returnANSI_BACK_BLUE
+ elifblue>=3:
+ returnANSI_HILITE+ANSI_BLUE
+ else:
+ returnANSI_NORMAL+ANSI_BLUE
+
+
[docs]defstrip_raw_codes(self,string):
+ """
+ Strips raw ANSI codes from a string.
+
+ Args:
+ string (str): The string to strip.
+
+ Returns:
+ string (str): The processed string.
+
+ """
+ returnself.ansi_regex.sub("",string)
+
+
[docs]defstrip_mxp(self,string):
+ """
+ Strips all MXP codes from a string.
+
+ Args:
+ string (str): The string to strip.
+
+ Returns:
+ string (str): The processed string.
+
+ """
+ returnself.mxp_sub.sub(r"\2",string)
+
+
[docs]defparse_ansi(self,string,strip_ansi=False,xterm256=False,mxp=False):
+ """
+ Parses a string, subbing color codes according to the stored
+ mapping.
+
+ Args:
+ string (str): The string to parse.
+ strip_ansi (boolean, optional): Strip all found ansi markup.
+ xterm256 (boolean, optional): If actually using xterm256 or if
+ these values should be converted to 16-color ANSI.
+ mxp (boolean, optional): Parse MXP commands in string.
+
+ Returns:
+ string (str): The parsed string.
+
+ """
+ ifhasattr(string,"_raw_string"):
+ ifstrip_ansi:
+ returnstring.clean()
+ else:
+ returnstring.raw()
+
+ ifnotstring:
+ return""
+
+ # check cached parsings
+ global_PARSE_CACHE
+ cachekey="%s-%s-%s-%s"%(string,strip_ansi,xterm256,mxp)
+ ifcachekeyin_PARSE_CACHE:
+ return_PARSE_CACHE[cachekey]
+
+ # pre-convert bright colors to xterm256 color tags
+ string=self.brightbg_sub.sub(self.sub_brightbg,string)
+
+ defdo_xterm256_fg(part):
+ returnself.sub_xterm256(part,xterm256,"fg")
+
+ defdo_xterm256_bg(part):
+ returnself.sub_xterm256(part,xterm256,"bg")
+
+ defdo_xterm256_gfg(part):
+ returnself.sub_xterm256(part,xterm256,"gfg")
+
+ defdo_xterm256_gbg(part):
+ returnself.sub_xterm256(part,xterm256,"gbg")
+
+ in_string=utils.to_str(string)
+
+ # do string replacement
+ parsed_string=[]
+ parts=self.ansi_escapes.split(in_string)+[" "]
+ forpart,sepinzip(parts[::2],parts[1::2]):
+ pstring=self.xterm256_fg_sub.sub(do_xterm256_fg,part)
+ pstring=self.xterm256_bg_sub.sub(do_xterm256_bg,pstring)
+ pstring=self.xterm256_gfg_sub.sub(do_xterm256_gfg,pstring)
+ pstring=self.xterm256_gbg_sub.sub(do_xterm256_gbg,pstring)
+ pstring=self.ansi_sub.sub(self.sub_ansi,pstring)
+ parsed_string.append("%s%s"%(pstring,sep[0].strip()))
+ parsed_string="".join(parsed_string)
+
+ ifnotmxp:
+ parsed_string=self.strip_mxp(parsed_string)
+
+ ifstrip_ansi:
+ # remove all ansi codes (including those manually
+ # inserted in string)
+ returnself.strip_raw_codes(parsed_string)
+
+ # cache and crop old cache
+ _PARSE_CACHE[cachekey]=parsed_string
+ iflen(_PARSE_CACHE)>_PARSE_CACHE_SIZE:
+ _PARSE_CACHE.popitem(last=False)
+
+ returnparsed_string
[docs]defparse_ansi(string,strip_ansi=False,parser=ANSI_PARSER,xterm256=False,mxp=False):
+ """
+ Parses a string, subbing color codes as needed.
+
+ Args:
+ string (str): The string to parse.
+ strip_ansi (bool, optional): Strip all ANSI sequences.
+ parser (ansi.AnsiParser, optional): A parser instance to use.
+ xterm256 (bool, optional): Support xterm256 or not.
+ mxp (bool, optional): Support MXP markup or not.
+
+ Returns:
+ string (str): The parsed string.
+
+ """
+ returnparser.parse_ansi(string,strip_ansi=strip_ansi,xterm256=xterm256,mxp=mxp)
+
+
+
[docs]defstrip_ansi(string,parser=ANSI_PARSER):
+ """
+ Strip all ansi from the string. This handles the Evennia-specific
+ markup.
+
+ Args:
+ string (str): The string to strip.
+ parser (ansi.AnsiParser, optional): The parser to use.
+
+ Returns:
+ string (str): The stripped string.
+
+ """
+ returnparser.parse_ansi(string,strip_ansi=True)
+
+
+
[docs]defstrip_raw_ansi(string,parser=ANSI_PARSER):
+ """
+ Remove raw ansi codes from string. This assumes pure
+ ANSI-bytecodes in the string.
+
+ Args:
+ string (str): The string to parse.
+ parser (bool, optional): The parser to use.
+
+ Returns:
+ string (str): the stripped string.
+
+ """
+ returnparser.strip_raw_codes(string)
+
+
+
[docs]defraw(string):
+ """
+ Escapes a string into a form which won't be colorized by the ansi
+ parser.
+
+ Returns:
+ string (str): The raw, escaped string.
+
+ """
+ returnstring.replace("{","{{").replace("|","||")
+
+
+# ------------------------------------------------------------
+#
+# ANSIString - ANSI-aware string class
+#
+# ------------------------------------------------------------
+
+
+def_spacing_preflight(func):
+ """
+ This wrapper function is used to do some preflight checks on
+ functions used for padding ANSIStrings.
+
+ """
+
+ @functools.wraps(func)
+ defwrapped(self,width=78,fillchar=None):
+ iffillcharisNone:
+ fillchar=" "
+ if(len(fillchar)!=1)or(notisinstance(fillchar,str)):
+ raiseTypeError("must be char, not %s"%type(fillchar))
+ ifnotisinstance(width,int):
+ raiseTypeError("integer argument expected, got %s"%type(width))
+ _difference=width-len(self)
+ if_difference<=0:
+ returnself
+ returnfunc(self,width,fillchar,_difference)
+
+ returnwrapped
+
+
+def_query_super(func_name):
+ """
+ Have the string class handle this with the cleaned string instead
+ of ANSIString.
+
+ """
+
+ defwrapped(self,*args,**kwargs):
+ returngetattr(self.clean(),func_name)(*args,**kwargs)
+
+ returnwrapped
+
+
+def_on_raw(func_name):
+ """
+ Like query_super, but makes the operation run on the raw string.
+
+ """
+
+ defwrapped(self,*args,**kwargs):
+ args=list(args)
+ try:
+ string=args.pop(0)
+ ifhasattr(string,"_raw_string"):
+ args.insert(0,string.raw())
+ else:
+ args.insert(0,string)
+ exceptIndexError:
+ # just skip out if there are no more strings
+ pass
+ result=getattr(self._raw_string,func_name)(*args,**kwargs)
+ ifisinstance(result,str):
+ returnANSIString(result,decoded=True)
+ returnresult
+
+ returnwrapped
+
+
+def_transform(func_name):
+ """
+ Some string functions, like those manipulating capital letters,
+ return a string the same length as the original. This function
+ allows us to do the same, replacing all the non-coded characters
+ with the resulting string.
+
+ """
+
+ defwrapped(self,*args,**kwargs):
+ replacement_string=_query_super(func_name)(self,*args,**kwargs)
+ to_string=[]
+ char_counter=0
+ forindexinrange(0,len(self._raw_string)):
+ ifindexinself._code_indexes:
+ to_string.append(self._raw_string[index])
+ elifindexinself._char_indexes:
+ to_string.append(replacement_string[char_counter])
+ char_counter+=1
+ returnANSIString(
+ "".join(to_string),
+ decoded=True,
+ code_indexes=self._code_indexes,
+ char_indexes=self._char_indexes,
+ clean_string=replacement_string,
+ )
+
+ returnwrapped
+
+
+
[docs]classANSIMeta(type):
+ """
+ Many functions on ANSIString are just light wrappers around the string
+ base class. We apply them here, as part of the classes construction.
+
+ """
+
+
[docs]classANSIString(str,metaclass=ANSIMeta):
+ """
+ Unicode-like object that is aware of ANSI codes.
+
+ This class can be used nearly identically to strings, in that it will
+ report string length, handle slices, etc, much like a string object
+ would. The methods should be used identically as string methods are.
+
+ There is at least one exception to this (and there may be more, though
+ they have not come up yet). When using ''.join() or u''.join() on an
+ ANSIString, color information will get lost. You must use
+ ANSIString('').join() to preserve color information.
+
+ This implementation isn't perfectly clean, as it doesn't really have an
+ understanding of what the codes mean in order to eliminate
+ redundant characters-- though cleaning up the strings might end up being
+ inefficient and slow without some C code when dealing with larger values.
+ Such enhancements could be made as an enhancement to ANSI_PARSER
+ if needed, however.
+
+ If one is going to use ANSIString, one should generally avoid converting
+ away from it until one is about to send information on the wire. This is
+ because escape sequences in the string may otherwise already be decoded,
+ and taken literally the second time around.
+
+ """
+
+ # A compiled Regex for the format mini-language: https://docs.python.org/3/library/string.html#formatspec
+ re_format=re.compile(
+ r"(?i)(?P<just>(?P<fill>.)?(?P<align>\<|\>|\=|\^))?(?P<sign>\+|\-| )?(?P<alt>\#)?"
+ r"(?P<zero>0)?(?P<width>\d+)?(?P<grouping>\_|\,)?(?:\.(?P<precision>\d+))?"
+ r"(?P<type>b|c|d|e|E|f|F|g|G|n|o|s|x|X|%)?"
+ )
+
+ def__new__(cls,*args,**kwargs):
+ """
+ When creating a new ANSIString, you may use a custom parser that has
+ the same attributes as the standard one, and you may declare the
+ string to be handled as already decoded. It is important not to double
+ decode strings, as escapes can only be respected once.
+
+ Internally, ANSIString can also passes itself precached code/character
+ indexes and clean strings to avoid doing extra work when combining
+ ANSIStrings.
+
+ """
+ string=args[0]
+ ifnotisinstance(string,str):
+ string=to_str(string)
+ parser=kwargs.get("parser",ANSI_PARSER)
+ decoded=kwargs.get("decoded",False)orhasattr(string,"_raw_string")
+ code_indexes=kwargs.pop("code_indexes",None)
+ char_indexes=kwargs.pop("char_indexes",None)
+ clean_string=kwargs.pop("clean_string",None)
+ # All True, or All False, not just one.
+ checks=[xisNoneforxin[code_indexes,char_indexes,clean_string]]
+ ifnotlen(set(checks))==1:
+ raiseValueError(
+ "You must specify code_indexes, char_indexes, "
+ "and clean_string together, or not at all."
+ )
+ ifnotall(checks):
+ decoded=True
+ ifnotdecoded:
+ # Completely new ANSI String
+ clean_string=parser.parse_ansi(string,strip_ansi=True,mxp=True)
+ string=parser.parse_ansi(string,xterm256=True,mxp=True)
+ elifclean_stringisnotNone:
+ # We have an explicit clean string.
+ pass
+ elifhasattr(string,"_clean_string"):
+ # It's already an ANSIString
+ clean_string=string._clean_string
+ code_indexes=string._code_indexes
+ char_indexes=string._char_indexes
+ string=string._raw_string
+ else:
+ # It's a string that has been pre-ansi decoded.
+ clean_string=parser.strip_raw_codes(string)
+
+ ifnotisinstance(string,str):
+ string=string.decode("utf-8")
+
+ ansi_string=super().__new__(ANSIString,to_str(clean_string))
+ ansi_string._raw_string=string
+ ansi_string._clean_string=clean_string
+ ansi_string._code_indexes=code_indexes
+ ansi_string._char_indexes=char_indexes
+ returnansi_string
+
+ def__str__(self):
+ returnself._raw_string
+
+ def__format__(self,format_spec):
+ """
+ This magic method covers ANSIString's behavior within a str.format() or f-string.
+
+ Current features supported: fill, align, width.
+
+ Args:
+ format_spec (str): The format specification passed by f-string or str.format(). This is a string such as
+ "0<30" which would mean "left justify to 30, filling with zeros". The full specification can be found
+ at https://docs.python.org/3/library/string.html#formatspec
+
+ Returns:
+ ansi_str (str): The formatted ANSIString's .raw() form, for display.
+ """
+ # This calls the compiled regex stored on ANSIString's class to analyze the format spec.
+ # It returns a dictionary.
+ format_data=self.re_format.match(format_spec).groupdict()
+ clean=self.clean()
+ base_output=ANSIString(self.raw())
+ align=format_data.get("align","<")
+ fill=format_data.get("fill"," ")
+
+ # Need to coerce width into an integer. We can be certain that it's numeric thanks to regex.
+ width=format_data.get("width",None)
+ ifwidthisNone:
+ width=len(clean)
+ else:
+ width=int(width)
+
+ ifalign=="<":
+ base_output=self.ljust(width,fill)
+ elifalign==">":
+ base_output=self.rjust(width,fill)
+ elifalign=="^":
+ base_output=self.center(width,fill)
+ elifalign=="=":
+ pass
+
+ # Return the raw string with ANSI markup, ready to be displayed.
+ returnbase_output.raw()
+
+ def__repr__(self):
+ """
+ Let's make the repr the command that would actually be used to
+ construct this object, for convenience and reference.
+
+ """
+ return"ANSIString(%s, decoded=True)"%repr(self._raw_string)
+
+
[docs]def__init__(self,*_,**kwargs):
+ """
+ When the ANSIString is first initialized, a few internal variables
+ have to be set.
+
+ The first is the parser. It is possible to replace Evennia's standard
+ ANSI parser with one of your own syntax if you wish, so long as it
+ implements the same interface.
+
+ The second is the _raw_string. This is the original "dumb" string
+ with ansi escapes that ANSIString represents.
+
+ The third thing to set is the _clean_string. This is a string that is
+ devoid of all ANSI Escapes.
+
+ Finally, _code_indexes and _char_indexes are defined. These are lookup
+ tables for which characters in the raw string are related to ANSI
+ escapes, and which are for the readable text.
+
+ """
+ self.parser=kwargs.pop("parser",ANSI_PARSER)
+ super().__init__()
+ ifself._code_indexesisNone:
+ self._code_indexes,self._char_indexes=self._get_indexes()
+
+ @staticmethod
+ def_shifter(iterable,offset):
+ """
+ Takes a list of integers, and produces a new one incrementing all
+ by a number.
+
+ """
+ ifnotoffset:
+ returniterable
+ return[i+offsetforiiniterable]
+
+ @classmethod
+ def_adder(cls,first,second):
+ """
+ Joins two ANSIStrings, preserving calculated info.
+
+ """
+
+ raw_string=first._raw_string+second._raw_string
+ clean_string=first._clean_string+second._clean_string
+ code_indexes=first._code_indexes[:]
+ char_indexes=first._char_indexes[:]
+ code_indexes.extend(cls._shifter(second._code_indexes,len(first._raw_string)))
+ char_indexes.extend(cls._shifter(second._char_indexes,len(first._raw_string)))
+ returnANSIString(
+ raw_string,
+ code_indexes=code_indexes,
+ char_indexes=char_indexes,
+ clean_string=clean_string,
+ )
+
+ def__add__(self,other):
+ """
+ We have to be careful when adding two strings not to reprocess things
+ that don't need to be reprocessed, lest we end up with escapes being
+ interpreted literally.
+
+ """
+ ifnotisinstance(other,str):
+ returnNotImplemented
+ ifnotisinstance(other,ANSIString):
+ other=ANSIString(other)
+ returnself._adder(self,other)
+
+ def__radd__(self,other):
+ """
+ Likewise, if we're on the other end.
+
+ """
+ ifnotisinstance(other,str):
+ returnNotImplemented
+ ifnotisinstance(other,ANSIString):
+ other=ANSIString(other)
+ returnself._adder(other,self)
+
+ def__getslice__(self,i,j):
+ """
+ This function is deprecated, so we just make it call the proper
+ function.
+
+ """
+ returnself.__getitem__(slice(i,j))
+
+ def_slice(self,slc):
+ """
+ This function takes a slice() object.
+
+ Slices have to be handled specially. Not only are they able to specify
+ a start and end with [x:y], but many forget that they can also specify
+ an interval with [x:y:z]. As a result, not only do we have to track
+ the ANSI Escapes that have played before the start of the slice, we
+ must also replay any in these intervals, should they exist.
+
+ Thankfully, slicing the _char_indexes table gives us the actual
+ indexes that need slicing in the raw string. We can check between
+ those indexes to figure out what escape characters need to be
+ replayed.
+
+ """
+ char_indexes=self._char_indexes
+ slice_indexes=char_indexes[slc]
+ # If it's the end of the string, we need to append final color codes.
+ ifnotslice_indexes:
+ # if we find no characters it may be because we are just outside
+ # of the interval, using an open-ended slice. We must replay all
+ # of the escape characters until/after this point.
+ ifchar_indexes:
+ ifslc.startisNoneandslc.stopisNone:
+ # a [:] slice of only escape characters
+ returnANSIString(self._raw_string[slc])
+ ifslc.startisNone:
+ # this is a [:x] slice
+ returnANSIString(self._raw_string[:char_indexes[0]])
+ ifslc.stopisNone:
+ # a [x:] slice
+ returnANSIString(self._raw_string[char_indexes[-1]+1:])
+ returnANSIString("")
+ try:
+ string=self[slc.startor0]._raw_string
+ exceptIndexError:
+ returnANSIString("")
+ last_mark=slice_indexes[0]
+ # Check between the slice intervals for escape sequences.
+ i=None
+ foriinslice_indexes[1:]:
+ forindexinrange(last_mark,i):
+ ifindexinself._code_indexes:
+ string+=self._raw_string[index]
+ last_mark=i
+ try:
+ string+=self._raw_string[i]
+ exceptIndexError:
+ # raw_string not long enough
+ pass
+ ifiisnotNone:
+ append_tail=self._get_interleving(char_indexes.index(i)+1)
+ else:
+ append_tail=""
+ returnANSIString(string+append_tail,decoded=True)
+
+ def__getitem__(self,item):
+ """
+ Gateway for slices and getting specific indexes in the ANSIString. If
+ this is a regexable ANSIString, it will get the data from the raw
+ string instead, bypassing ANSIString's intelligent escape skipping,
+ for reasons explained in the __new__ method's docstring.
+
+ """
+ ifisinstance(item,slice):
+ # Slices must be handled specially.
+ returnself._slice(item)
+ try:
+ self._char_indexes[item]
+ exceptIndexError:
+ raiseIndexError("ANSIString Index out of range")
+ # Get character codes after the index as well.
+ ifself._char_indexes[-1]==self._char_indexes[item]:
+ append_tail=self._get_interleving(item+1)
+ else:
+ append_tail=""
+ item=self._char_indexes[item]
+
+ clean=self._raw_string[item]
+ result=""
+ # Get the character they're after, and replay all escape sequences
+ # previous to it.
+ forindexinrange(0,item+1):
+ ifindexinself._code_indexes:
+ result+=self._raw_string[index]
+ returnANSIString(result+clean+append_tail,decoded=True)
+
+
[docs]defclean(self):
+ """
+ Return a string object *without* the ANSI escapes.
+
+ Returns:
+ clean_string (str): A unicode object with no ANSI escapes.
+
+ """
+ returnself._clean_string
+
+
[docs]defraw(self):
+ """
+ Return a string object with the ANSI escapes.
+
+ Returns:
+ raw (str): A unicode object *with* the raw ANSI escape sequences.
+
+ """
+ returnself._raw_string
+
+
[docs]defpartition(self,sep,reverse=False):
+ """
+ Splits once into three sections (with the separator being the middle section)
+
+ We use the same techniques we used in split() to make sure each are
+ colored.
+
+ Args:
+ sep (str): The separator to split the string on.
+ reverse (boolean): Whether to split the string on the last
+ occurrence of the separator rather than the first.
+
+ Returns:
+ ANSIString: The part of the string before the separator
+ ANSIString: The separator itself
+ ANSIString: The part of the string after the separator.
+
+ """
+ ifhasattr(sep,"_clean_string"):
+ sep=sep.clean()
+ ifreverse:
+ parent_result=self._clean_string.rpartition(sep)
+ else:
+ parent_result=self._clean_string.partition(sep)
+ current_index=0
+ result=tuple()
+ forsectioninparent_result:
+ result+=(self[current_index:current_index+len(section)],)
+ current_index+=len(section)
+ returnresult
+
+ def_get_indexes(self):
+ """
+ Two tables need to be made, one which contains the indexes of all
+ readable characters, and one which contains the indexes of all ANSI
+ escapes. It's important to remember that ANSI escapes require more
+ that one character at a time, though no readable character needs more
+ than one character, since the string base class abstracts that away
+ from us. However, several readable characters can be placed in a row.
+
+ We must use regexes here to figure out where all the escape sequences
+ are hiding in the string. Then we use the ranges of their starts and
+ ends to create a final, comprehensive list of all indexes which are
+ dedicated to code, and all dedicated to text.
+
+ It's possible that only one of these tables is actually needed, the
+ other assumed to be what isn't in the first.
+
+ """
+
+ code_indexes=[]
+ formatchinself.parser.ansi_regex.finditer(self._raw_string):
+ code_indexes.extend(list(range(match.start(),match.end())))
+ ifnotcode_indexes:
+ # Plain string, no ANSI codes.
+ returncode_indexes,list(range(0,len(self._raw_string)))
+ # all indexes not occupied by ansi codes are normal characters
+ char_indexes=[iforiinrange(len(self._raw_string))ifinotincode_indexes]
+ returncode_indexes,char_indexes
+
+ def_get_interleving(self,index):
+ """
+ Get the code characters from the given slice end to the next
+ character.
+
+ """
+ try:
+ index=self._char_indexes[index-1]
+ exceptIndexError:
+ return""
+ s=""
+ whileTrue:
+ index+=1
+ ifindexinself._char_indexes:
+ break
+ elifindexinself._code_indexes:
+ s+=self._raw_string[index]
+ else:
+ break
+ returns
+
+ def__mul__(self,other):
+ """
+ Multiplication method. Implemented for performance reasons.
+
+ """
+ ifnotisinstance(other,int):
+ returnNotImplemented
+ raw_string=self._raw_string*other
+ clean_string=self._clean_string*other
+ code_indexes=self._code_indexes[:]
+ char_indexes=self._char_indexes[:]
+ foriinrange(other):
+ code_indexes.extend(self._shifter(self._code_indexes,i*len(self._raw_string)))
+ char_indexes.extend(self._shifter(self._char_indexes,i*len(self._raw_string)))
+ returnANSIString(
+ raw_string,
+ code_indexes=code_indexes,
+ char_indexes=char_indexes,
+ clean_string=clean_string,
+ )
+
+ def__rmul__(self,other):
+ returnself.__mul__(other)
+
+
[docs]defsplit(self,by=None,maxsplit=-1):
+ """
+ Splits a string based on a separator.
+
+ Stolen from PyPy's pure Python string implementation, tweaked for
+ ANSIString.
+
+ PyPy is distributed under the MIT licence.
+ http://opensource.org/licenses/MIT
+
+ Args:
+ by (str): A string to search for which will be used to split
+ the string. For instance, ',' for 'Hello,world' would
+ result in ['Hello', 'world']
+ maxsplit (int): The maximum number of times to split the string.
+ For example, a maxsplit of 2 with a by of ',' on the string
+ 'Hello,world,test,string' would result in
+ ['Hello', 'world', 'test,string']
+ Returns:
+ result (list of ANSIStrings): A list of ANSIStrings derived from
+ this string.
+
+ """
+ drop_spaces=byisNone
+ ifdrop_spaces:
+ by=" "
+
+ bylen=len(by)
+ ifbylen==0:
+ raiseValueError("empty separator")
+
+ res=[]
+ start=0
+ whilemaxsplit!=0:
+ next=self._clean_string.find(by,start)
+ ifnext<0:
+ break
+ # Get character codes after the index as well.
+ res.append(self[start:next])
+ start=next+bylen
+ maxsplit-=1# NB. if it's already < 0, it stays < 0
+
+ res.append(self[start:len(self)])
+ ifdrop_spaces:
+ return[partforpartinresifpart!=""]
+ returnres
+
+
[docs]defrsplit(self,by=None,maxsplit=-1):
+ """
+ Like split, but starts from the end of the string rather than the
+ beginning.
+
+ Stolen from PyPy's pure Python string implementation, tweaked for
+ ANSIString.
+
+ PyPy is distributed under the MIT licence.
+ http://opensource.org/licenses/MIT
+
+ Args:
+ by (str): A string to search for which will be used to split
+ the string. For instance, ',' for 'Hello,world' would
+ result in ['Hello', 'world']
+ maxsplit (int): The maximum number of times to split the string.
+ For example, a maxsplit of 2 with a by of ',' on the string
+ 'Hello,world,test,string' would result in
+ ['Hello,world', 'test', 'string']
+ Returns:
+ result (list of ANSIStrings): A list of ANSIStrings derived from
+ this string.
+
+ """
+ res=[]
+ end=len(self)
+ drop_spaces=byisNone
+ ifdrop_spaces:
+ by=" "
+ bylen=len(by)
+ ifbylen==0:
+ raiseValueError("empty separator")
+
+ whilemaxsplit!=0:
+ next=self._clean_string.rfind(by,0,end)
+ ifnext<0:
+ break
+ # Get character codes after the index as well.
+ res.append(self[next+bylen:end])
+ end=next
+ maxsplit-=1# NB. if it's already < 0, it stays < 0
+
+ res.append(self[:end])
+ res.reverse()
+ ifdrop_spaces:
+ return[partforpartinresifpart!=""]
+ returnres
+
+
[docs]defstrip(self,chars=None):
+ """
+ Strip from both ends, taking ANSI markers into account.
+
+ Args:
+ chars (str, optional): A string containing individual characters
+ to strip off of both ends of the string. By default, any blank
+ spaces are trimmed.
+ Returns:
+ result (ANSIString): A new ANSIString with the ends trimmed of the
+ relevant characters.
+
+ """
+ clean=self._clean_string
+ raw=self._raw_string
+
+ # count continuous sequence of chars from left and right
+ nlen=len(clean)
+ nlstripped=nlen-len(clean.lstrip(chars))
+ nrstripped=nlen-len(clean.rstrip(chars))
+
+ # within the stripped regions, only retain parts of the raw
+ # string *not* matching the clean string (these are ansi/mxp tags)
+ lstripped=""
+ ic,ir1=0,0
+ whilenlstripped:
+ ific>=nlstripped:
+ break
+ elifraw[ir1]!=clean[ic]:
+ lstripped+=raw[ir1]
+ else:
+ ic+=1
+ ir1+=1
+ rstripped=""
+ ic,ir2=nlen-1,len(raw)-1
+ whilenrstripped:
+ ifnlen-ic>nrstripped:
+ break
+ elifraw[ir2]!=clean[ic]:
+ rstripped+=raw[ir2]
+ else:
+ ic-=1
+ ir2-=1
+ rstripped=rstripped[::-1]
+ returnANSIString(lstripped+raw[ir1:ir2+1]+rstripped)
+
+
[docs]deflstrip(self,chars=None):
+ """
+ Strip from the left, taking ANSI markers into account.
+
+ Args:
+ chars (str, optional): A string containing individual characters
+ to strip off of the left end of the string. By default, any
+ blank spaces are trimmed.
+ Returns:
+ result (ANSIString): A new ANSIString with the left end trimmed of
+ the relevant characters.
+
+ """
+ clean=self._clean_string
+ raw=self._raw_string
+
+ # count continuous sequence of chars from left and right
+ nlen=len(clean)
+ nlstripped=nlen-len(clean.lstrip(chars))
+ # within the stripped regions, only retain parts of the raw
+ # string *not* matching the clean string (these are ansi/mxp tags)
+ lstripped=""
+ ic,ir1=0,0
+ whilenlstripped:
+ ific>=nlstripped:
+ break
+ elifraw[ir1]!=clean[ic]:
+ lstripped+=raw[ir1]
+ else:
+ ic+=1
+ ir1+=1
+ returnANSIString(lstripped+raw[ir1:])
+
+
[docs]defrstrip(self,chars=None):
+ """
+ Strip from the right, taking ANSI markers into account.
+
+ Args:
+ chars (str, optional): A string containing individual characters
+ to strip off of the right end of the string. By default, any
+ blank spaces are trimmed.
+ Returns:
+ result (ANSIString): A new ANSIString with the right end trimmed of
+ the relevant characters.
+
+ """
+ clean=self._clean_string
+ raw=self._raw_string
+ nlen=len(clean)
+ nrstripped=nlen-len(clean.rstrip(chars))
+ rstripped=""
+ ic,ir2=nlen-1,len(raw)-1
+ whilenrstripped:
+ ifnlen-ic>nrstripped:
+ break
+ elifraw[ir2]!=clean[ic]:
+ rstripped+=raw[ir2]
+ else:
+ ic-=1
+ ir2-=1
+ rstripped=rstripped[::-1]
+ returnANSIString(raw[:ir2+1]+rstripped)
+
+
[docs]defjoin(self,iterable):
+ """
+ Joins together strings in an iterable, using this string between each
+ one.
+
+ NOTE: This should always be used for joining strings when ANSIStrings
+ are involved. Otherwise color information will be discarded by python,
+ due to details in the C implementation of strings.
+
+ Args:
+ iterable (list of strings): A list of strings to join together
+
+ Returns:
+ ANSIString: A single string with all of the iterable's
+ contents concatenated, with this string between each.
+
+ Examples:
+ ::
+
+ >>> ANSIString(', ').join(['up', 'right', 'left', 'down'])
+ ANSIString('up, right, left, down')
+
+ """
+ result=ANSIString("")
+ last_item=None
+ foriteminiterable:
+ iflast_itemisnotNone:
+ result+=self._raw_string
+ ifnotisinstance(item,ANSIString):
+ item=ANSIString(item)
+ result+=item
+ last_item=item
+ returnresult
+
+ def_filler(self,char,amount):
+ """
+ Generate a line of characters in a more efficient way than just adding
+ ANSIStrings.
+
+ """
+ ifnotisinstance(char,ANSIString):
+ line=char*amount
+ returnANSIString(
+ char*amount,
+ code_indexes=[],
+ char_indexes=list(range(0,len(line))),
+ clean_string=char,
+ )
+ try:
+ start=char._code_indexes[0]
+ exceptIndexError:
+ start=None
+ end=char._char_indexes[0]
+ prefix=char._raw_string[start:end]
+ postfix=char._raw_string[end+1:]
+ line=char._clean_string*amount
+ code_indexes=[iforiinrange(0,len(prefix))]
+ length=len(prefix)+len(line)
+ code_indexes.extend([iforiinrange(length,length+len(postfix))])
+ char_indexes=self._shifter(list(range(0,len(line))),len(prefix))
+ raw_string=prefix+line+postfix
+ returnANSIString(
+ raw_string,clean_string=line,char_indexes=char_indexes,code_indexes=code_indexes
+ )
+
+ # The following methods should not be called with the '_difference' argument explicitly. This is
+ # data provided by the wrapper _spacing_preflight.
+
[docs]@_spacing_preflight
+ defcenter(self,width,fillchar,_difference):
+ """
+ Center some text with some spaces padding both sides.
+
+ Args:
+ width (int): The target width of the output string.
+ fillchar (str): A single character string to pad the output string
+ with.
+ Returns:
+ result (ANSIString): A string padded on both ends with fillchar.
+
+ """
+ remainder=_difference%2
+ _difference//=2
+ spacing=self._filler(fillchar,_difference)
+ result=spacing+self+spacing+self._filler(fillchar,remainder)
+ returnresult
+
+
[docs]@_spacing_preflight
+ defljust(self,width,fillchar,_difference):
+ """
+ Left justify some text.
+
+ Args:
+ width (int): The target width of the output string.
+ fillchar (str): A single character string to pad the output string
+ with.
+ Returns:
+ result (ANSIString): A string padded on the right with fillchar.
+
+ """
+ returnself+self._filler(fillchar,_difference)
+
+
[docs]@_spacing_preflight
+ defrjust(self,width,fillchar,_difference):
+ """
+ Right justify some text.
+
+ Args:
+ width (int): The target width of the output string.
+ fillchar (str): A single character string to pad the output string
+ with.
+ Returns:
+ result (ANSIString): A string padded on the left with fillchar.
+
+ """
+ returnself._filler(fillchar,_difference)+self
+"""
+This module contains the core methods for the Batch-command- and
+Batch-code-processors respectively. In short, these are two different
+ways to build a game world using a normal text-editor without having
+to do so 'on the fly' in-game. They also serve as an automatic backup
+so you can quickly recreate a world also after a server reset. The
+functions in this module is meant to form the backbone of a system
+called and accessed through game commands.
+
+The Batch-command processor is the simplest. It simply runs a list of
+in-game commands in sequence by reading them from a text file. The
+advantage of this is that the builder only need to remember the normal
+in-game commands. They are also executing with full permission checks
+etc, making it relatively safe for builders to use. The drawback is
+that in-game there is really a builder-character walking around
+building things, and it can be important to create rooms and objects
+in the right order, so the character can move between them. Also
+objects that affects players (such as mobs, dark rooms etc) will
+affect the building character too, requiring extra care to turn
+off/on.
+
+The Batch-code processor is a more advanced system that accepts full
+Python code, executing in chunks. The advantage of this is much more
+power; practically anything imaginable can be coded and handled using
+the batch-code processor. There is no in-game character that moves and
+that can be affected by what is being built - the database is
+populated on the fly. The drawback is safety and entry threshold - the
+code is executed as would any server code, without mud-specific
+permission-checks, and you have full access to modifying objects
+etc. You also need to know Python and Evennia's API. Hence it's
+recommended that the batch-code processor is limited only to
+superusers or highly trusted staff.
+
+Batch-Command processor file syntax
+-----------------------------------
+
+The batch-command processor accepts 'batchcommand files' e.g
+`batch.ev`, containing a sequence of valid Evennia commands in a
+simple format. The engine runs each command in sequence, as if they
+had been run at the game prompt.
+
+Each Evennia command must be delimited by a line comment to mark its
+end. This way entire game worlds can be created and planned offline; it is
+especially useful in order to create long room descriptions where a
+real offline text editor is often much better than any online text
+editor or prompt.
+
+There is only one batchcommand-specific entry to use in a batch-command
+files (all others are just like in-game commands):
+
+- `#INSERT path.batchcmdfile` - this as the first entry on a line will
+ import and run a batch.ev file in this position, as if it was
+ written in this file.
+
+
+Example of batch.ev file:
+::
+
+ # batch file
+ # all lines starting with # are comments; they also indicate
+ # that a command definition is over.
+
+ @create box
+
+ # this comment ends the @create command.
+
+ @set box/desc = A large box.
+
+ Inside are some scattered piles of clothing.
+
+
+ It seems the bottom of the box is a bit loose.
+
+ # Again, this comment indicates the @set command is over. Note how
+ # the description could be freely added. Excess whitespace on a line
+ # is ignored. An empty line in the command definition is parsed as a \n
+ # (so two empty lines becomes a new paragraph).
+
+ @teleport #221
+
+ # (Assuming #221 is a warehouse or something.)
+ # (remember, this comment ends the @teleport command! Don'f forget it)
+
+ # Example of importing another file at this point.
+ #INSERT examples.batch
+
+ @drop box
+
+ # Done, the box is in the warehouse! (this last comment is not necessary to
+ # close the @drop command since it's the end of the file)
+
+
+An example batch file is `contrib/examples/batch_example.ev`.
+
+
+Batch-Code processor file syntax
+--------------------------------
+
+The Batch-code processor accepts full python modules (e.g. `batch.py`)
+that looks identical to normal Python files. The difference from
+importing and running any Python module is that the batch-code module
+is loaded as a file and executed directly, so changes to the file will
+apply immediately without a server @reload.
+
+Optionally, one can add some special commented tokens to split the
+execution of the code for the benefit of the batchprocessor's
+interactive- and debug-modes. This allows to conveniently step through
+the code and re-run sections of it easily during development.
+
+Code blocks are marked by commented tokens alone on a line:
+
+- `#HEADER` - This denotes code that should be pasted at the top of all
+ other code. Multiple HEADER statements - regardless of where
+ it exists in the file - is the same as one big block.
+ Observe that changes to variables made in one block is not
+ preserved between blocks!
+- `#CODE` - This designates a code block that will be executed like a
+ stand-alone piece of code together with any HEADER(s)
+ defined. It is mainly used as a way to mark stop points for
+ the interactive mode of the batchprocessor. If no CODE block
+ is defined in the module, the entire module (including HEADERS)
+ is assumed to be a CODE block.
+- `#INSERT path.filename` - This imports another batch_code.py file and
+ runs it in the given position. The inserted file will retain
+ its own HEADERs which will not be mixed with the headers of
+ this file.
+
+Importing works as normal. The following variables are automatically
+made available in the script namespace.
+
+- `caller` - The object executing the batchscript
+- `DEBUG` - This is a boolean marking if the batchprocessor is running
+ in debug mode. It can be checked to e.g. delete created objects
+ when running a CODE block multiple times during testing.
+ (avoids creating a slew of same-named db objects)
+
+Example batch.py file:
+::
+
+ #HEADER
+
+ from django.conf import settings
+ from evennia.utils import create
+ from types import basetypes
+
+ GOLD = 10
+
+ #CODE
+
+ obj = create.create_object(basetypes.Object)
+ obj2 = create.create_object(basetypes.Object)
+ obj.location = caller.location
+ obj.db.gold = GOLD
+ caller.msg("The object was created!")
+
+ if DEBUG:
+ obj.delete()
+ obj2.delete()
+
+ #INSERT another_batch_file
+
+ #CODE
+
+ script = create.create_script()
+
+----
+
+"""
+importre
+importcodecs
+importtraceback
+importsys
+fromdjango.confimportsettings
+fromevennia.utilsimportutils
+
+_ENCODINGS=settings.ENCODINGS
+_RE_INSERT=re.compile(r"^\#INSERT (.*)$",re.MULTILINE)
+_RE_CLEANBLOCK=re.compile(r"^\#.*?$|^\s*$",re.MULTILINE)
+_RE_CMD_SPLIT=re.compile(r"^\#.*?$",re.MULTILINE)
+_RE_CODE_OR_HEADER=re.compile(
+ r"((?:\A|^)#CODE|(?:/A|^)#HEADER|\A)(.*?)$(.*?)(?=^#CODE.*?$|^#HEADER.*?$|\Z)",
+ re.MULTILINE+re.DOTALL,
+)
+
+
+# -------------------------------------------------------------
+# Helper function
+# -------------------------------------------------------------
+
+
+
[docs]defread_batchfile(pythonpath,file_ending=".py"):
+ """
+ This reads the contents of a batch-file. Filename is considered
+ to be a python path to a batch file relative the directory
+ specified in `settings.py`.
+
+ file_ending specify which batchfile ending should be assumed (.ev
+ or .py). The ending should not be included in the python path.
+
+ Args:
+ pythonpath (str): A dot-python path to a file.
+ file_ending (str): The file ending of this file (.ev or .py)
+
+ Returns:
+ str: The text content of the batch file.
+
+ Raises:
+ IOError: If problems reading file.
+
+ """
+
+ # find all possible absolute paths
+ abspaths=utils.pypath_to_realpath(pythonpath,file_ending,settings.BASE_BATCHPROCESS_PATHS)
+ ifnotabspaths:
+ raiseIOError("Absolute batchcmd paths could not be found.")
+ text=None
+ decoderr=[]
+ forabspathinabspaths:
+ # try different paths, until we get a match
+ # we read the file directly into string.
+ forfile_encodingin_ENCODINGS:
+ # try different encodings, in order
+ try:
+ withcodecs.open(abspath,"r",encoding=file_encoding)asfobj:
+ text=fobj.read()
+ except(ValueError,UnicodeDecodeError)ase:
+ # this means an encoding error; try another encoding
+ decoderr.append(str(e))
+ continue
+ break
+ ifnottextanddecoderr:
+ raiseUnicodeDecodeError("\n".join(decoderr),bytearray(),0,0,"")
+
+ returntext
[docs]classBatchCommandProcessor(object):
+ """
+ This class implements a batch-command processor.
+
+ """
+
+
[docs]defparse_file(self,pythonpath):
+ """
+ This parses the lines of a batchfile according to the following
+ rules:
+
+ 1. `#` at the beginning of a line marks the end of the command before
+ it. It is also a comment and any number of # can exist on
+ subsequent lines (but not inside comments).
+ 2. `#INSERT` at the beginning of a line imports another
+ batch-cmd file file and pastes it into the batch file as if
+ it was written there.
+ 3. Commands are placed alone at the beginning of a line and their
+ arguments are considered to be everything following (on any
+ number of lines) until the next comment line beginning with #.
+ 4. Newlines are ignored in command definitions
+ 5. A completely empty line in a command line definition is condered
+ a newline (so two empty lines is a paragraph).
+ 6. Excess spaces and indents inside arguments are stripped.
+
+ """
+
+ text="".join(read_batchfile(pythonpath,file_ending=".ev"))
+
+ defreplace_insert(match):
+ """Map replace entries"""
+ try:
+ path=match.group(1)
+ return"\n#\n".join(self.parse_file(path))
+ exceptIOErroraserr:
+ raiseIOError("#INSERT {} failed.".format(path))
+
+ text=_RE_INSERT.sub(replace_insert,text)
+ commands=_RE_CMD_SPLIT.split(text)
+ commands=[c.strip("\r\n")forcincommands]
+ commands=[cforcincommandsifc]
+
+ returncommands
[docs]classBatchCodeProcessor(object):
+ """
+ This implements a batch-code processor
+
+ """
+
+
[docs]defparse_file(self,pythonpath):
+ """
+ This parses the lines of a batchfile according to the following
+ rules:
+
+ Args:
+ pythonpath (str): The dot-python path to the file.
+
+ Returns:
+ codeblocks (list): A list of all #CODE blocks, each with
+ prepended #HEADER data. If no #CODE blocks were found,
+ this will be a list of one element.
+
+ Notes:
+ 1. Code before a #CODE/HEADER block are considered part of
+ the first code/header block or is the ONLY block if no
+ #CODE/HEADER blocks are defined.
+ 2. Lines starting with #HEADER starts a header block (ends other blocks)
+ 3. Lines starting with #CODE begins a code block (ends other blocks)
+ 4. Lines starting with #INSERT are on form #INSERT filename. Code from
+ this file are processed with their headers *separately* before
+ being inserted at the point of the #INSERT.
+ 5. Code after the last block is considered part of the last header/code
+ block
+
+ """
+
+ text="".join(read_batchfile(pythonpath,file_ending=".py"))
+
+ defreplace_insert(match):
+ """Run parse_file on the import before sub:ing it into this file"""
+ path=match.group(1)
+ try:
+ return"# batchcode insert (%s):"%path+"\n".join(self.parse_file(path))
+ exceptIOErroraserr:
+ raiseIOError("#INSERT {} failed.".format(path))
+
+ # process and then insert code from all #INSERTS
+ text=_RE_INSERT.sub(replace_insert,text)
+
+ headers=[]
+ codes=[]
+ forimatch,matchinenumerate(list(_RE_CODE_OR_HEADER.finditer(text))):
+ mtype=match.group(1).strip()
+ # we need to handle things differently at the start of the file
+ ifmtype:
+ istart,iend=match.span(3)
+ else:
+ istart,iend=match.start(2),match.end(3)
+ code=text[istart:iend]
+ ifmtype=="#HEADER":
+ headers.append(code)
+ else:# either #CODE or matching from start of file
+ codes.append(code)
+
+ # join all headers together to one
+ header="# batchcode header:\n%s\n\n"%"\n\n".join(headers)ifheaderselse""
+ # add header to each code block
+ codes=["%s# batchcode code:\n%s"%(header,code)forcodeincodes]
+ returncodes
+
+
[docs]defcode_exec(self,code,extra_environ=None,debug=False):
+ """
+ Execute a single code block, including imports and appending
+ global vars.
+
+ Args:
+ code (str): Code to run.
+ extra_environ (dict): Environment variables to run with code.
+ debug (bool, optional): Set the DEBUG variable in the execution
+ namespace.
+
+ Returns:
+ err (str or None): An error code or None (ok).
+
+ """
+ # define the execution environment
+ environdict={"settings_module":settings,"DEBUG":debug}
+ forkey,valueinextra_environ.items():
+ environdict[key]=value
+
+ # initializing the django settings at the top of code
+ code=(
+ "# batchcode evennia initialization: \n"
+ "try: settings_module.configure()\n"
+ "except RuntimeError: pass\n"
+ "finally: del settings_module\n\n%s"%code
+ )
+
+ # execute the block
+ try:
+ exec(code,environdict)
+ exceptException:
+ etype,value,tb=sys.exc_info()
+
+ fname=tb_filename(tb)
+ fortbintb_iter(tb):
+ iffname!=tb_filename(tb):
+ break
+ lineno=tb.tb_lineno-1
+ err=""
+ foriline,lineinenumerate(code.split("\n")):
+ ifiline==lineno:
+ err+="\n|w%02i|n: %s"%(iline+1,line)
+ eliflineno-5<iline<lineno+5:
+ err+="\n%02i: %s"%(iline+1,line)
+
+ err+="\n".join(traceback.format_exception(etype,value,tb))
+ returnerr
+ returnNone
+"""
+Containers
+
+Containers are storage classes usually initialized from a setting. They
+represent Singletons and acts as a convenient place to find resources (
+available as properties on the singleton)
+
+evennia.GLOBAL_SCRIPTS
+evennia.OPTION_CLASSES
+
+"""
+
+
+fromdjango.confimportsettings
+fromevennia.utils.utilsimportclass_from_module,callables_from_module
+fromevennia.utilsimportlogger
+
+
+SCRIPTDB=None
+
+
+
[docs]classContainer(object):
+ """
+ Base container class. A container is simply a storage object whose
+ properties can be acquired as a property on it. This is generally
+ considered a read-only affair.
+
+ The container is initialized by a list of modules containing callables.
+
+ """
+
+ storage_modules=[]
+
+
[docs]def__init__(self):
+ """
+ Read data from module.
+
+ """
+ self.loaded_data=None
+
+
[docs]defload_data(self):
+ """
+ Delayed import to avoid eventual circular imports from inside
+ the storage modules.
+
+ """
+ ifself.loaded_dataisNone:
+ self.loaded_data={}
+ formoduleinself.storage_modules:
+ self.loaded_data.update(callables_from_module(module))
[docs]defget(self,key,default=None):
+ """
+ Retrive data by key (in case of not knowing it beforehand).
+
+ Args:
+ key (str): The name of the script.
+ default (any, optional): Value to return if key is not found.
+
+ Returns:
+ any (any): The data loaded on this container.
+
+ """
+ self.load_data()
+ returnself.loaded_data.get(key,default)
+
+
[docs]defall(self):
+ """
+ Get all stored data
+
+ Returns:
+ scripts (list): All global script objects stored on the container.
+
+ """
+ self.load_data()
+ returnlist(self.loaded_data.values())
+
+
+
[docs]classOptionContainer(Container):
+ """
+ Loads and stores the final list of OPTION CLASSES.
+
+ Can access these as properties or dictionary-contents.
+ """
+
+ storage_modules=settings.OPTION_CLASS_MODULES
+
+
+
[docs]classGlobalScriptContainer(Container):
+ """
+ Simple Handler object loaded by the Evennia API to contain and manage a
+ game's Global Scripts. This will list global Scripts created on their own
+ but will also auto-(re)create scripts defined in `settings.GLOBAL_SCRIPTS`.
+
+ Example:
+ import evennia
+ evennia.GLOBAL_SCRIPTS.scriptname
+
+ Note:
+ This does not use much of the BaseContainer since it's not loading
+ callables from settings but a custom dict of tuples.
+
+ """
+
+
[docs]def__init__(self):
+ """
+ Note: We must delay loading of typeclasses since this module may get
+ initialized before Scripts are actually initialized.
+
+ """
+ self.typeclass_storage=None
+ self.loaded_data={
+ key:{}ifdataisNoneelsedataforkey,datainsettings.GLOBAL_SCRIPTS.items()
+ }
[docs]defstart(self):
+ """
+ Called last in evennia.__init__ to initialize the container late
+ (after script typeclasses have finished loading).
+
+ We include all global scripts in the handler and
+ make sure to auto-load time-based scripts.
+
+ """
+ # populate self.typeclass_storage
+ self.load_data()
+
+ # start registered scripts
+ forkeyinself.loaded_data:
+ self._load_script(key)
+
+
[docs]defload_data(self):
+ """
+ This delayed import avoids trying to load Scripts before they are
+ initialized.
+
+ """
+ ifself.typeclass_storageisNone:
+ self.typeclass_storage={}
+ forkey,datainself.loaded_data.items():
+ try:
+ typeclass=data.get("typeclass",settings.BASE_SCRIPT_TYPECLASS)
+ self.typeclass_storage[key]=class_from_module(typeclass)
+ exceptImportErroraserr:
+ logger.log_err(
+ f"GlobalScriptContainer could not start global script {key}: {err}"
+ )
+
+
[docs]defget(self,key,default=None):
+ """
+ Retrive data by key (in case of not knowing it beforehand). Any
+ scripts that are in settings.GLOBAL_SCRIPTS that are not found
+ will be recreated on-demand.
+
+ Args:
+ key (str): The name of the script.
+ default (any, optional): Value to return if key is not found
+ at all on this container (i.e it cannot be loaded at all).
+
+ Returns:
+ any (any): The data loaded on this container.
+ """
+ res=self._get_scripts(key)
+ ifnotres:
+ ifkeyinself.loaded_data:
+ # recreate if we have the info
+ returnself._load_script(key)ordefault
+ returndefault
+ returnres
+
+
[docs]defall(self):
+ """
+ Get all global scripts. Note that this will not auto-start
+ scripts defined in settings.
+
+ Returns:
+ scripts (list): All global script objects stored on the container.
+
+ """
+ self.typeclass_storage=None
+ self.load_data()
+ forkeyinself.loaded_data:
+ self._load_script(key)
+ returnself._get_scripts(None)
+
+
+# Create all singletons
+
+GLOBAL_SCRIPTS=GlobalScriptContainer()
+OPTION_CLASSES=OptionContainer()
+
+"""
+This module gathers all the essential database-creation
+functions for the game engine's various object types.
+
+Only objects created 'stand-alone' are in here, e.g. object Attributes
+are always created directly through their respective objects.
+
+Each creation_* function also has an alias named for the entity being
+created, such as create_object() and object(). This is for
+consistency with the utils.search module and allows you to do the
+shorter "create.object()".
+
+The respective object managers hold more methods for manipulating and
+searching objects already existing in the database.
+
+Models covered:
+ Objects
+ Scripts
+ Help
+ Message
+ Channel
+ Accounts
+"""
+fromdjango.confimportsettings
+fromdjango.dbimportIntegrityError
+fromdjango.utilsimporttimezone
+fromevennia.utilsimportlogger
+fromevennia.serverimportsignals
+fromevennia.utils.utilsimportmake_iter,class_from_module,dbid_to_obj
+
+# delayed imports
+_User=None
+_ObjectDB=None
+_Object=None
+_Script=None
+_ScriptDB=None
+_HelpEntry=None
+_Msg=None
+_Account=None
+_AccountDB=None
+_to_object=None
+_ChannelDB=None
+_channelhandler=None
+
+
+# limit symbol import from API
+__all__=(
+ "create_object",
+ "create_script",
+ "create_help_entry",
+ "create_message",
+ "create_channel",
+ "create_account",
+)
+
+_GA=object.__getattribute__
+
+#
+# Game Object creation
+
+
+
[docs]defcreate_object(
+ typeclass=None,
+ key=None,
+ location=None,
+ home=None,
+ permissions=None,
+ locks=None,
+ aliases=None,
+ tags=None,
+ destination=None,
+ report_to=None,
+ nohome=False,
+ attributes=None,
+ nattributes=None,
+):
+ """
+
+ Create a new in-game object.
+
+ Keyword Args:
+ typeclass (class or str): Class or python path to a typeclass.
+ key (str): Name of the new object. If not set, a name of
+ #dbref will be set.
+ home (Object or str): Obj or #dbref to use as the object's
+ home location.
+ permissions (list): A list of permission strings or tuples (permstring, category).
+ locks (str): one or more lockstrings, separated by semicolons.
+ aliases (list): A list of alternative keys or tuples (aliasstring, category).
+ tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data).
+ destination (Object or str): Obj or #dbref to use as an Exit's
+ target.
+ report_to (Object): The object to return error messages to.
+ nohome (bool): This allows the creation of objects without a
+ default home location; only used when creating the default
+ location itself or during unittests.
+ attributes (list): Tuples on the form (key, value) or (key, value, category),
+ (key, value, lockstring) or (key, value, lockstring, default_access).
+ to set as Attributes on the new object.
+ nattributes (list): Non-persistent tuples on the form (key, value). Note that
+ adding this rarely makes sense since this data will not survive a reload.
+
+ Returns:
+ object (Object): A newly created object of the given typeclass.
+
+ Raises:
+ ObjectDB.DoesNotExist: If trying to create an Object with
+ `location` or `home` that can't be found.
+
+ """
+ global_ObjectDB
+ ifnot_ObjectDB:
+ fromevennia.objects.modelsimportObjectDBas_ObjectDB
+
+ typeclass=typeclassiftypeclasselsesettings.BASE_OBJECT_TYPECLASS
+
+ # convenience converters to avoid common usage mistake
+ permissions=make_iter(permissions)ifpermissionsisnotNoneelseNone
+ locks=make_iter(locks)iflocksisnotNoneelseNone
+ aliases=make_iter(aliases)ifaliasesisnotNoneelseNone
+ tags=make_iter(tags)iftagsisnotNoneelseNone
+ attributes=make_iter(attributes)ifattributesisnotNoneelseNone
+
+ ifisinstance(typeclass,str):
+ # a path is given. Load the actual typeclass
+ typeclass=class_from_module(typeclass,settings.TYPECLASS_PATHS)
+
+ # Setup input for the create command. We use ObjectDB as baseclass here
+ # to give us maximum freedom (the typeclasses will load
+ # correctly when each object is recovered).
+
+ location=dbid_to_obj(location,_ObjectDB)
+ destination=dbid_to_obj(destination,_ObjectDB)
+ home=dbid_to_obj(home,_ObjectDB)
+ ifnothome:
+ try:
+ home=dbid_to_obj(settings.DEFAULT_HOME,_ObjectDB)ifnotnohomeelseNone
+ except_ObjectDB.DoesNotExist:
+ raise_ObjectDB.DoesNotExist(
+ "settings.DEFAULT_HOME (= '%s') does not exist, or the setting is malformed."
+ %settings.DEFAULT_HOME
+ )
+
+ # create new instance
+ new_object=typeclass(
+ db_key=key,
+ db_location=location,
+ db_destination=destination,
+ db_home=home,
+ db_typeclass_path=typeclass.path,
+ )
+ # store the call signature for the signal
+ new_object._createdict=dict(
+ key=key,
+ location=location,
+ destination=destination,
+ home=home,
+ typeclass=typeclass.path,
+ permissions=permissions,
+ locks=locks,
+ aliases=aliases,
+ tags=tags,
+ report_to=report_to,
+ nohome=nohome,
+ attributes=attributes,
+ nattributes=nattributes,
+ )
+ # this will trigger the save signal which in turn calls the
+ # at_first_save hook on the typeclass, where the _createdict can be
+ # used.
+ new_object.save()
+
+ signals.SIGNAL_OBJECT_POST_CREATE.send(sender=new_object)
+
+ returnnew_object
+
+
+# alias for create_object
+object=create_object
+
+
+#
+# Script creation
+
+
+
[docs]defcreate_script(
+ typeclass=None,
+ key=None,
+ obj=None,
+ account=None,
+ locks=None,
+ interval=None,
+ start_delay=None,
+ repeats=None,
+ persistent=None,
+ autostart=True,
+ report_to=None,
+ desc=None,
+ tags=None,
+ attributes=None,
+):
+ """
+ Create a new script. All scripts are a combination of a database
+ object that communicates with the database, and an typeclass that
+ 'decorates' the database object into being different types of
+ scripts. It's behaviour is similar to the game objects except
+ scripts has a time component and are more limited in scope.
+
+ Keyword Args:
+ typeclass (class or str): Class or python path to a typeclass.
+ key (str): Name of the new object. If not set, a name of
+ #dbref will be set.
+ obj (Object): The entity on which this Script sits. If this
+ is `None`, we are creating a "global" script.
+ account (Account): The account on which this Script sits. It is
+ exclusiv to `obj`.
+ locks (str): one or more lockstrings, separated by semicolons.
+ interval (int): The triggering interval for this Script, in
+ seconds. If unset, the Script will not have a timing
+ component.
+ start_delay (bool): If `True`, will wait `interval` seconds
+ before triggering the first time.
+ repeats (int): The number of times to trigger before stopping.
+ If unset, will repeat indefinitely.
+ persistent (bool): If this Script survives a server shutdown
+ or not (all Scripts will survive a reload).
+ autostart (bool): If this Script will start immediately when
+ created or if the `start` method must be called explicitly.
+ report_to (Object): The object to return error messages to.
+ desc (str): Optional description of script
+ tags (list): List of tags or tuples (tag, category).
+ attributes (list): List of tuples `(key, value)`, `(key, value, category)`,
+ `(key, value, category, lockstring)` or
+ `(key, value, category, lockstring, default_access)`.
+
+ Returns:
+ script (obj): An instance of the script created
+
+ See evennia.scripts.manager for methods to manipulate existing
+ scripts in the database.
+
+ """
+ global_ScriptDB
+ ifnot_ScriptDB:
+ fromevennia.scripts.modelsimportScriptDBas_ScriptDB
+
+ typeclass=typeclassiftypeclasselsesettings.BASE_SCRIPT_TYPECLASS
+
+ ifisinstance(typeclass,str):
+ # a path is given. Load the actual typeclass
+ typeclass=class_from_module(typeclass,settings.TYPECLASS_PATHS)
+
+ # validate input
+ kwarg={}
+ ifkey:
+ kwarg["db_key"]=key
+ ifaccount:
+ kwarg["db_account"]=dbid_to_obj(account,_AccountDB)
+ ifobj:
+ kwarg["db_obj"]=dbid_to_obj(obj,_ObjectDB)
+ ifinterval:
+ kwarg["db_interval"]=max(0,interval)
+ ifstart_delay:
+ kwarg["db_start_delay"]=start_delay
+ ifrepeats:
+ kwarg["db_repeats"]=max(0,repeats)
+ ifpersistent:
+ kwarg["db_persistent"]=persistent
+ ifdesc:
+ kwarg["db_desc"]=desc
+ tags=make_iter(tags)iftagsisnotNoneelseNone
+ attributes=make_iter(attributes)ifattributesisnotNoneelseNone
+
+ # create new instance
+ new_script=typeclass(**kwarg)
+
+ # store the call signature for the signal
+ new_script._createdict=dict(
+ key=key,
+ obj=obj,
+ account=account,
+ locks=locks,
+ interval=interval,
+ start_delay=start_delay,
+ repeats=repeats,
+ persistent=persistent,
+ autostart=autostart,
+ report_to=report_to,
+ desc=desc,
+ tags=tags,
+ attributes=attributes,
+ )
+ # this will trigger the save signal which in turn calls the
+ # at_first_save hook on the typeclass, where the _createdict
+ # can be used.
+ new_script.save()
+
+ ifnotnew_script.id:
+ # this happens in the case of having a repeating script with `repeats=1` and
+ # `start_delay=False` - the script will run once and immediately stop before save is over.
+ returnNone
+
+ signals.SIGNAL_SCRIPT_POST_CREATE.send(sender=new_script)
+
+ returnnew_script
+
+
+# alias
+script=create_script
+
+
+#
+# Help entry creation
+#
+
+
+
[docs]defcreate_help_entry(key,entrytext,category="General",locks=None,aliases=None,tags=None):
+ """
+ Create a static help entry in the help database. Note that Command
+ help entries are dynamic and directly taken from the __doc__
+ entries of the command. The database-stored help entries are
+ intended for more general help on the game, more extensive info,
+ in-game setting information and so on.
+
+ Args:
+ key (str): The name of the help entry.
+ entrytext (str): The body of te help entry
+ category (str, optional): The help category of the entry.
+ locks (str, optional): A lockstring to restrict access.
+ aliases (list of str, optional): List of alternative (likely shorter) keynames.
+ tags (lst, optional): List of tags or tuples `(tag, category)`.
+
+ Returns:
+ help (HelpEntry): A newly created help entry.
+
+ """
+ global_HelpEntry
+ ifnot_HelpEntry:
+ fromevennia.help.modelsimportHelpEntryas_HelpEntry
+
+ try:
+ new_help=_HelpEntry()
+ new_help.key=key
+ new_help.entrytext=entrytext
+ new_help.help_category=category
+ iflocks:
+ new_help.locks.add(locks)
+ ifaliases:
+ new_help.aliases.add(make_iter(aliases))
+ iftags:
+ new_help.tags.batch_add(*tags)
+ new_help.save()
+ returnnew_help
+ exceptIntegrityError:
+ string="Could not add help entry: key '%s' already exists."%key
+ logger.log_err(string)
+ returnNone
+ exceptException:
+ logger.log_trace()
+ returnNone
+
+ signals.SIGNAL_HELPENTRY_POST_CREATE.send(sender=new_help)
+
+
+# alias
+help_entry=create_help_entry
+
+
+#
+# Comm system methods
+
+
+
[docs]defcreate_message(
+ senderobj,message,channels=None,receivers=None,locks=None,tags=None,header=None
+):
+ """
+ Create a new communication Msg. Msgs represent a unit of
+ database-persistent communication between entites.
+
+ Args:
+ senderobj (Object or Account): The entity sending the Msg.
+ message (str): Text with the message. Eventual headers, titles
+ etc should all be included in this text string. Formatting
+ will be retained.
+ channels (Channel, key or list): A channel or a list of channels to
+ send to. The channels may be actual channel objects or their
+ unique key strings.
+ receivers (Object, Account, str or list): An Account/Object to send
+ to, or a list of them. May be Account objects or accountnames.
+ locks (str): Lock definition string.
+ tags (list): A list of tags or tuples `(tag, category)`.
+ header (str): Mime-type or other optional information for the message
+
+ Notes:
+ The Comm system is created very open-ended, so it's fully possible
+ to let a message both go to several channels and to several
+ receivers at the same time, it's up to the command definitions to
+ limit this as desired.
+
+ """
+ global_Msg
+ ifnot_Msg:
+ fromevennia.comms.modelsimportMsgas_Msg
+ ifnotmessage:
+ # we don't allow empty messages.
+ returnNone
+ new_message=_Msg(db_message=message)
+ new_message.save()
+ forsenderinmake_iter(senderobj):
+ new_message.senders=sender
+ new_message.header=header
+ forchannelinmake_iter(channels):
+ new_message.channels=channel
+ forreceiverinmake_iter(receivers):
+ new_message.receivers=receiver
+ iflocks:
+ new_message.locks.add(locks)
+ iftags:
+ new_message.tags.batch_add(*tags)
+
+ new_message.save()
+ returnnew_message
[docs]defcreate_channel(
+ key,aliases=None,desc=None,locks=None,keep_log=True,typeclass=None,tags=None
+):
+ """
+ Create A communication Channel. A Channel serves as a central hub
+ for distributing Msgs to groups of people without specifying the
+ receivers explicitly. Instead accounts may 'connect' to the channel
+ and follow the flow of messages. By default the channel allows
+ access to all old messages, but this can be turned off with the
+ keep_log switch.
+
+ Args:
+ key (str): This must be unique.
+
+ Keyword Args:
+ aliases (list of str): List of alternative (likely shorter) keynames.
+ desc (str): A description of the channel, for use in listings.
+ locks (str): Lockstring.
+ keep_log (bool): Log channel throughput.
+ typeclass (str or class): The typeclass of the Channel (not
+ often used).
+ tags (list): A list of tags or tuples `(tag, category)`.
+
+ Returns:
+ channel (Channel): A newly created channel.
+
+ """
+ typeclass=typeclassiftypeclasselsesettings.BASE_CHANNEL_TYPECLASS
+
+ ifisinstance(typeclass,str):
+ # a path is given. Load the actual typeclass
+ typeclass=class_from_module(typeclass,settings.TYPECLASS_PATHS)
+
+ # create new instance
+ new_channel=typeclass(db_key=key)
+
+ # store call signature for the signal
+ new_channel._createdict=dict(
+ key=key,aliases=aliases,desc=desc,locks=locks,keep_log=keep_log,tags=tags
+ )
+
+ # this will trigger the save signal which in turn calls the
+ # at_first_save hook on the typeclass, where the _createdict can be
+ # used.
+ new_channel.save()
+
+ signals.SIGNAL_CHANNEL_POST_CREATE.send(sender=new_channel)
+
+ returnnew_channel
[docs]defcreate_account(
+ key,
+ email,
+ password,
+ typeclass=None,
+ is_superuser=False,
+ locks=None,
+ permissions=None,
+ tags=None,
+ attributes=None,
+ report_to=None,
+):
+ """
+ This creates a new account.
+
+ Args:
+ key (str): The account's name. This should be unique.
+ email (str or None): Email on valid addr@addr.domain form. If
+ the empty string, will be set to None.
+ password (str): Password in cleartext.
+
+ Keyword Args:
+ typeclass (str): The typeclass to use for the account.
+ is_superuser (bool): Wether or not this account is to be a superuser
+ locks (str): Lockstring.
+ permission (list): List of permission strings.
+ tags (list): List of Tags on form `(key, category[, data])`
+ attributes (list): List of Attributes on form
+ `(key, value [, category, [,lockstring [, default_pass]]])`
+ report_to (Object): An object with a msg() method to report
+ errors to. If not given, errors will be logged.
+
+ Returns:
+ Account: The newly created Account.
+ Raises:
+ ValueError: If `key` already exists in database.
+
+
+ Notes:
+ Usually only the server admin should need to be superuser, all
+ other access levels can be handled with more fine-grained
+ permissions or groups. A superuser bypasses all lock checking
+ operations and is thus not suitable for play-testing the game.
+
+ """
+ global_AccountDB
+ ifnot_AccountDB:
+ fromevennia.accounts.modelsimportAccountDBas_AccountDB
+
+ typeclass=typeclassiftypeclasselsesettings.BASE_ACCOUNT_TYPECLASS
+ locks=make_iter(locks)iflocksisnotNoneelseNone
+ permissions=make_iter(permissions)ifpermissionsisnotNoneelseNone
+ tags=make_iter(tags)iftagsisnotNoneelseNone
+ attributes=make_iter(attributes)ifattributesisnotNoneelseNone
+
+ ifisinstance(typeclass,str):
+ # a path is given. Load the actual typeclass.
+ typeclass=class_from_module(typeclass,settings.TYPECLASS_PATHS)
+
+ # setup input for the create command. We use AccountDB as baseclass
+ # here to give us maximum freedom (the typeclasses will load
+ # correctly when each object is recovered).
+
+ ifnotemail:
+ email=None
+ if_AccountDB.objects.filter(username__iexact=key):
+ raiseValueError("An Account with the name '%s' already exists."%key)
+
+ # this handles a given dbref-relocate to an account.
+ report_to=dbid_to_obj(report_to,_AccountDB)
+
+ # create the correct account entity, using the setup from
+ # base django auth.
+ now=timezone.now()
+ email=typeclass.objects.normalize_email(email)
+ new_account=typeclass(
+ username=key,
+ email=email,
+ is_staff=is_superuser,
+ is_superuser=is_superuser,
+ last_login=now,
+ date_joined=now,
+ )
+ ifpasswordisnotNone:
+ # the password may be None for 'fake' accounts, like bots
+ valid,error=new_account.validate_password(password,new_account)
+ ifnotvalid:
+ raiseerror
+
+ new_account.set_password(password)
+
+ new_account._createdict=dict(
+ locks=locks,permissions=permissions,report_to=report_to,tags=tags,attributes=attributes
+ )
+ # saving will trigger the signal that calls the
+ # at_first_save hook on the typeclass, where the _createdict
+ # can be used.
+ new_account.save()
+
+ # note that we don't send a signal here, that is sent from the Account.create helper method
+ # instead.
+
+ returnnew_account
+"""
+This module handles serialization of arbitrary python structural data,
+intended primarily to be stored in the database. It also supports
+storing Django model instances (which plain pickle cannot do).
+
+This serialization is used internally by the server, notably for
+storing data in Attributes and for piping data to process pools.
+
+The purpose of dbserialize is to handle all forms of data. For
+well-structured non-arbitrary exchange, such as communicating with a
+rich web client, a simpler JSON serialization makes more sense.
+
+This module also implements the `SaverList`, `SaverDict` and `SaverSet`
+classes. These are iterables that track their position in a nested
+structure and makes sure to send updates up to their root. This is
+used by Attributes - without it, one would not be able to update mutables
+in-situ, e.g `obj.db.mynestedlist[3][5] = 3` would never be saved and
+be out of sync with the database.
+
+"""
+fromfunctoolsimportupdate_wrapper
+fromcollectionsimportdeque,OrderedDict,defaultdict
+fromcollections.abcimportMutableSequence,MutableSet,MutableMapping
+
+try:
+ frompickleimportdumps,loads
+exceptImportError:
+ frompickleimportdumps,loads
+fromdjango.core.exceptionsimportObjectDoesNotExist
+fromdjango.contrib.contenttypes.modelsimportContentType
+fromdjango.utils.safestringimportSafeString
+fromevennia.utils.utilsimportuses_database,is_iter,to_str,to_bytes
+fromevennia.utilsimportlogger
+
+__all__=("to_pickle","from_pickle","do_pickle","do_unpickle","dbserialize","dbunserialize")
+
+PICKLE_PROTOCOL=2
+
+
+# message to send if editing an already deleted Attribute in a savermutable
+_ERROR_DELETED_ATTR=(
+ "{cls_name}{obj} has had its root Attribute deleted. "
+ "It must be cast to a {non_saver_name} before it can be modified further."
+)
+
+
+def_get_mysql_db_version():
+ """
+ This is a helper method for specifically getting the version
+ string of a MySQL database.
+
+ Returns:
+ mysql_version (str): The currently used mysql database
+ version.
+
+ """
+ fromdjango.dbimportconnection
+
+ conn=connection.cursor()
+ conn.execute("SELECT VERSION()")
+ version=conn.fetchone()
+ returnversionandstr(version[0])or""
+
+
+# initialization and helpers
+
+
+_GA=object.__getattribute__
+_SA=object.__setattr__
+_FROM_MODEL_MAP=None
+_TO_MODEL_MAP=None
+_IGNORE_DATETIME_MODELS=None
+_SESSION_HANDLER=None
+
+
+def_IS_PACKED_DBOBJ(o):
+ returnisinstance(o,tuple)andlen(o)==4ando[0]=="__packed_dbobj__"
+
+
+def_IS_PACKED_SESSION(o):
+ returnisinstance(o,tuple)andlen(o)==3ando[0]=="__packed_session__"
+
+
+ifuses_database("mysql")and_get_mysql_db_version()<"5.6.4":
+ # mysql <5.6.4 don't support millisecond precision
+ _DATESTRING="%Y:%m:%d-%H:%M:%S:000000"
+else:
+ _DATESTRING="%Y:%m:%d-%H:%M:%S:%f"
+
+
+def_TO_DATESTRING(obj):
+ """
+ Creates datestring hash.
+
+ Args:
+ obj (Object): Database object.
+
+ Returns:
+ datestring (str): A datestring hash.
+
+ """
+ try:
+ return_GA(obj,"db_date_created").strftime(_DATESTRING)
+ exceptAttributeError:
+ # this can happen if object is not yet saved - no datestring is then set
+ try:
+ obj.save()
+ exceptAttributeError:
+ # we have received a None object, for example due to an erroneous save.
+ returnNone
+ return_GA(obj,"db_date_created").strftime(_DATESTRING)
+
+
+def_init_globals():
+ """Lazy importing to avoid circular import issues"""
+ global_FROM_MODEL_MAP,_TO_MODEL_MAP,_SESSION_HANDLER,_IGNORE_DATETIME_MODELS
+ ifnot_FROM_MODEL_MAP:
+ _FROM_MODEL_MAP=defaultdict(str)
+ _FROM_MODEL_MAP.update(dict((c.model,c.natural_key())forcinContentType.objects.all()))
+ ifnot_TO_MODEL_MAP:
+ fromdjango.confimportsettings
+
+ _TO_MODEL_MAP=defaultdict(str)
+ _TO_MODEL_MAP.update(
+ dict((c.natural_key(),c.model_class())forcinContentType.objects.all())
+ )
+ _IGNORE_DATETIME_MODELS=[]
+ forsrc_key,dst_keyinsettings.ATTRIBUTE_STORED_MODEL_RENAME:
+ _TO_MODEL_MAP[src_key]=_TO_MODEL_MAP.get(dst_key,None)
+ _IGNORE_DATETIME_MODELS.append(src_key)
+ ifnot_SESSION_HANDLER:
+ fromevennia.server.sessionhandlerimportSESSION_HANDLERas_SESSION_HANDLER
+
+
+#
+# SaverList, SaverDict, SaverSet - Attribute-specific helper classes and functions
+#
+
+
+def_save(method):
+ """method decorator that saves data to Attribute"""
+
+ defsave_wrapper(self,*args,**kwargs):
+ self.__doc__=method.__doc__
+ ret=method(self,*args,**kwargs)
+ self._save_tree()
+ returnret
+
+ returnupdate_wrapper(save_wrapper,method)
+
+
+class_SaverMutable(object):
+ """
+ Parent class for properly handling of nested mutables in
+ an Attribute. If not used something like
+ obj.db.mylist[1][2] = "test" (allocation to a nested list)
+ will not save the updated value to the database.
+ """
+
+ def__init__(self,*args,**kwargs):
+ """store all properties for tracking the tree"""
+ self._parent=kwargs.pop("_parent",None)
+ self._db_obj=kwargs.pop("_db_obj",None)
+ self._data=None
+
+ def__bool__(self):
+ """Make sure to evaluate as False if empty"""
+ returnbool(self._data)
+
+ def_save_tree(self):
+ """recursively traverse back up the tree, save when we reach the root"""
+ ifself._parent:
+ self._parent._save_tree()
+ elifself._db_obj:
+ ifnotself._db_obj.pk:
+ cls_name=self.__class__.__name__
+ try:
+ non_saver_name=cls_name.split("_Saver",1)[1].lower()
+ exceptIndexError:
+ non_saver_name=cls_name
+ raiseValueError(
+ _ERROR_DELETED_ATTR.format(
+ cls_name=cls_name,obj=self,non_saver_name=non_saver_name
+ )
+ )
+ self._db_obj.value=self
+ else:
+ logger.log_err("_SaverMutable %s has no root Attribute to save to."%self)
+
+ def_convert_mutables(self,data):
+ """converts mutables to Saver* variants and assigns ._parent property"""
+
+ defprocess_tree(item,parent):
+ """recursively populate the tree, storing parents"""
+ dtype=type(item)
+ ifdtypein(str,int,float,bool,tuple):
+ returnitem
+ elifdtype==list:
+ dat=_SaverList(_parent=parent)
+ dat._data.extend(process_tree(val,dat)forvalinitem)
+ returndat
+ elifdtype==dict:
+ dat=_SaverDict(_parent=parent)
+ dat._data.update((key,process_tree(val,dat))forkey,valinitem.items())
+ returndat
+ elifdtype==set:
+ dat=_SaverSet(_parent=parent)
+ dat._data.update(process_tree(val,dat)forvalinitem)
+ returndat
+ returnitem
+
+ returnprocess_tree(data,self)
+
+ def__repr__(self):
+ returnself._data.__repr__()
+
+ def__len__(self):
+ returnself._data.__len__()
+
+ def__iter__(self):
+ returnself._data.__iter__()
+
+ def__getitem__(self,key):
+ returnself._data.__getitem__(key)
+
+ def__eq__(self,other):
+ returnself._data==other
+
+ def__ne__(self,other):
+ returnself._data!=other
+
+ def__lt__(self,other):
+ returnself._data<other
+
+ def__gt__(self,other):
+ returnself._data>other
+
+ @_save
+ def__setitem__(self,key,value):
+ self._data.__setitem__(key,self._convert_mutables(value))
+
+ @_save
+ def__delitem__(self,key):
+ self._data.__delitem__(key)
+
+
+class_SaverList(_SaverMutable,MutableSequence):
+ """
+ A list that saves itself to an Attribute when updated.
+ """
+
+ def__init__(self,*args,**kwargs):
+ super().__init__(*args,**kwargs)
+ self._data=list()
+
+ @_save
+ def__iadd__(self,otherlist):
+ self._data=self._data.__add__(otherlist)
+ returnself._data
+
+ def__add__(self,otherlist):
+ returnlist(self._data)+otherlist
+
+ @_save
+ definsert(self,index,value):
+ self._data.insert(index,self._convert_mutables(value))
+
+ def__eq__(self,other):
+ try:
+ returnlist(self._data)==list(other)
+ exceptTypeError:
+ returnFalse
+
+ def__ne__(self,other):
+ try:
+ returnlist(self._data)!=list(other)
+ exceptTypeError:
+ returnTrue
+
+ defindex(self,value,*args):
+ returnself._data.index(value,*args)
+
+ @_save
+ defsort(self,*,key=None,reverse=False):
+ self._data.sort(key=key,reverse=reverse)
+
+ defcopy(self):
+ returnself._data.copy()
+
+
+class_SaverDict(_SaverMutable,MutableMapping):
+ """
+ A dict that stores changes to an Attribute when updated
+ """
+
+ def__init__(self,*args,**kwargs):
+ super().__init__(*args,**kwargs)
+ self._data=dict()
+
+ defhas_key(self,key):
+ returnkeyinself._data
+
+ @_save
+ defupdate(self,*args,**kwargs):
+ self._data.update(*args,**kwargs)
+
+
+class_SaverSet(_SaverMutable,MutableSet):
+ """
+ A set that saves to an Attribute when updated
+ """
+
+ def__init__(self,*args,**kwargs):
+ super().__init__(*args,**kwargs)
+ self._data=set()
+
+ def__contains__(self,value):
+ returnself._data.__contains__(value)
+
+ @_save
+ defadd(self,value):
+ self._data.add(self._convert_mutables(value))
+
+ @_save
+ defdiscard(self,value):
+ self._data.discard(value)
+
+
+class_SaverOrderedDict(_SaverMutable,MutableMapping):
+ """
+ An ordereddict that can be saved and operated on.
+ """
+
+ def__init__(self,*args,**kwargs):
+ super().__init__(*args,**kwargs)
+ self._data=OrderedDict()
+
+ defhas_key(self,key):
+ returnkeyinself._data
+
+
+class_SaverDeque(_SaverMutable):
+ """
+ A deque that can be saved and operated on.
+ """
+
+ def__init__(self,*args,**kwargs):
+ super().__init__(*args,**kwargs)
+ self._data=deque()
+
+ @_save
+ defappend(self,*args,**kwargs):
+ self._data.append(*args,**kwargs)
+
+ @_save
+ defappendleft(self,*args,**kwargs):
+ self._data.appendleft(*args,**kwargs)
+
+ @_save
+ defclear(self):
+ self._data.clear()
+
+ @_save
+ defextendleft(self,*args,**kwargs):
+ self._data.extendleft(*args,**kwargs)
+
+ # maxlen property
+ def_getmaxlen(self):
+ returnself._data.maxlen
+
+ def_setmaxlen(self,value):
+ self._data.maxlen=value
+
+ def_delmaxlen(self):
+ delself._data.maxlen
+
+ maxlen=property(_getmaxlen,_setmaxlen,_delmaxlen)
+
+ @_save
+ defpop(self,*args,**kwargs):
+ returnself._data.pop(*args,**kwargs)
+
+ @_save
+ defpopleft(self,*args,**kwargs):
+ returnself._data.popleft(*args,**kwargs)
+
+ @_save
+ defreverse(self):
+ self._data.reverse()
+
+ @_save
+ defrotate(self,*args):
+ self._data.rotate(*args)
+
+ @_save
+ defremove(self,*args):
+ self._data.remove(*args)
+
+
+_DESERIALIZE_MAPPING={
+ _SaverList.__name__:list,
+ _SaverDict.__name__:dict,
+ _SaverSet.__name__:set,
+ _SaverOrderedDict.__name__:OrderedDict,
+ _SaverDeque.__name__:deque,
+}
+
+
+defdeserialize(obj):
+ """
+ Make sure to *fully* decouple a structure from the database, by turning all _Saver*-mutables
+ inside it back into their normal Python forms.
+
+ """
+
+ def_iter(obj):
+ typ=type(obj)
+ tname=typ.__name__
+ iftnamein("_SaverDict","dict"):
+ return{_iter(key):_iter(val)forkey,valinobj.items()}
+ eliftnamein_DESERIALIZE_MAPPING:
+ return_DESERIALIZE_MAPPING[tname](_iter(val)forvalinobj)
+ elifis_iter(obj):
+ returntyp(_iter(val)forvalinobj)
+ returnobj
+
+ return_iter(obj)
+
+
+#
+# serialization helpers
+
+
+defpack_dbobj(item):
+ """
+ Check and convert django database objects to an internal representation.
+
+ Args:
+ item (any): A database entity to pack
+
+ Returns:
+ packed (any or tuple): Either returns the original input item
+ or the packing tuple `("__packed_dbobj__", key, creation_time, id)`.
+
+ """
+ _init_globals()
+ obj=item
+ natural_key=_FROM_MODEL_MAP[
+ hasattr(obj,"id")
+ andhasattr(obj,"db_date_created")
+ andhasattr(obj,"__dbclass__")
+ andobj.__dbclass__.__name__.lower()
+ ]
+ # build the internal representation as a tuple
+ # ("__packed_dbobj__", key, creation_time, id)
+ return(
+ natural_key
+ and("__packed_dbobj__",natural_key,_TO_DATESTRING(obj),_GA(obj,"id"))
+ oritem
+ )
+
+
+defunpack_dbobj(item):
+ """
+ Check and convert internal representations back to Django database
+ models.
+
+ Args:
+ item (packed_dbobj): The fact that item is a packed dbobj
+ should be checked before this call.
+
+ Returns:
+ unpacked (any): Either the original input or converts the
+ internal store back to a database representation (its
+ typeclass is returned if applicable).
+
+ """
+ _init_globals()
+ try:
+ obj=item[3]and_TO_MODEL_MAP[item[1]].objects.get(id=item[3])
+ exceptObjectDoesNotExist:
+ returnNone
+ exceptTypeError:
+ ifhasattr(item,"pk"):
+ # this happens if item is already an obj
+ returnitem
+ returnNone
+ ifitem[1]in_IGNORE_DATETIME_MODELS:
+ # if we are replacing models we ignore the datatime
+ returnobj
+ else:
+ # even if we got back a match, check the sanity of the date (some
+ # databases may 're-use' the id)
+ return_TO_DATESTRING(obj)==item[2]andobjorNone
+
+
+defpack_session(item):
+ """
+ Handle the safe serializion of Sessions objects (these contain
+ hidden references to database objects (accounts, puppets) so they
+ can't be safely serialized).
+
+ Args:
+ item (Session)): This item must have all properties of a session
+ before entering this call.
+
+ Returns:
+ packed (tuple or None): A session-packed tuple on the form
+ `(__packed_session__, sessid, conn_time)`. If this sessid
+ does not match a session in the Session handler, None is returned.
+
+ """
+ _init_globals()
+ session=_SESSION_HANDLER.get(item.sessid)
+ ifsessionandsession.conn_time==item.conn_time:
+ # we require connection times to be identical for the Session
+ # to be accepted as actually being a session (sessids gets
+ # reused all the time).
+ return(
+ item.conn_time
+ anditem.sessid
+ and("__packed_session__",_GA(item,"sessid"),_GA(item,"conn_time"))
+ )
+ returnNone
+
+
+defunpack_session(item):
+ """
+ Check and convert internal representations back to Sessions.
+
+ Args:
+ item (packed_session): The fact that item is a packed session
+ should be checked before this call.
+
+ Returns:
+ unpacked (any): Either the original input or converts the
+ internal store back to a Session. If Session no longer
+ exists, None will be returned.
+ """
+ _init_globals()
+ session=_SESSION_HANDLER.get(item[1])
+ ifsessionandsession.conn_time==item[2]:
+ # we require connection times to be identical for the Session
+ # to be accepted as the same as the one stored (sessids gets
+ # reused all the time).
+ returnsession
+ returnNone
+
+
+#
+# Access methods
+
+
+
[docs]defto_pickle(data):
+ """
+ This prepares data on arbitrary form to be pickled. It handles any
+ nested structure and returns data on a form that is safe to pickle
+ (including having converted any database models to their internal
+ representation). We also convert any Saver*-type objects back to
+ their normal representations, they are not pickle-safe.
+
+ Args:
+ data (any): Data to pickle.
+
+ Returns:
+ data (any): Pickled data.
+
+ """
+
+ defprocess_item(item):
+ """Recursive processor and identification of data"""
+ dtype=type(item)
+ ifdtypein(str,int,float,bool,bytes,SafeString):
+ returnitem
+ elifdtype==tuple:
+ returntuple(process_item(val)forvalinitem)
+ elifdtypein(list,_SaverList):
+ return[process_item(val)forvalinitem]
+ elifdtypein(dict,_SaverDict):
+ returndict((process_item(key),process_item(val))forkey,valinitem.items())
+ elifdtypein(set,_SaverSet):
+ returnset(process_item(val)forvalinitem)
+ elifdtypein(OrderedDict,_SaverOrderedDict):
+ returnOrderedDict((process_item(key),process_item(val))forkey,valinitem.items())
+ elifdtypein(deque,_SaverDeque):
+ returndeque(process_item(val)forvalinitem)
+
+ elifhasattr(item,"__iter__"):
+ # we try to conserve the iterable class, if not convert to list
+ try:
+ returnitem.__class__([process_item(val)forvalinitem])
+ except(AttributeError,TypeError):
+ return[process_item(val)forvalinitem]
+ elifhasattr(item,"sessid")andhasattr(item,"conn_time"):
+ returnpack_session(item)
+ try:
+ returnpack_dbobj(item)
+ exceptTypeError:
+ returnitem
+ exceptException:
+ logger.log_err(f"The object {item} of type {type(item)} could not be stored.")
+ raise
+
+ returnprocess_item(data)
+
+
+# @transaction.autocommit
+
[docs]deffrom_pickle(data,db_obj=None):
+ """
+ This should be fed a just de-pickled data object. It will be converted back
+ to a form that may contain database objects again. Note that if a database
+ object was removed (or changed in-place) in the database, None will be
+ returned.
+
+ Args:
+ data (any): Pickled data to unpickle.
+ db_obj (Atribute, any): This is the model instance (normally
+ an Attribute) that _Saver*-type iterables (_SaverList etc)
+ will save to when they update. It must have a 'value' property
+ that saves assigned data to the database. Skip if not
+ serializing onto a given object. If db_obj is given, this
+ function will convert lists, dicts and sets to their
+ `_SaverList`, `_SaverDict` and `_SaverSet` counterparts.
+
+ Returns:
+ data (any): Unpickled data.
+
+ """
+
+ defprocess_item(item):
+ """Recursive processor and identification of data"""
+ dtype=type(item)
+ ifdtypein(str,int,float,bool,bytes,SafeString):
+ returnitem
+ elif_IS_PACKED_DBOBJ(item):
+ # this must be checked before tuple
+ returnunpack_dbobj(item)
+ elif_IS_PACKED_SESSION(item):
+ returnunpack_session(item)
+ elifdtype==tuple:
+ returntuple(process_item(val)forvalinitem)
+ elifdtype==dict:
+ returndict((process_item(key),process_item(val))forkey,valinitem.items())
+ elifdtype==set:
+ returnset(process_item(val)forvalinitem)
+ elifdtype==OrderedDict:
+ returnOrderedDict((process_item(key),process_item(val))forkey,valinitem.items())
+ elifdtype==deque:
+ returndeque(process_item(val)forvalinitem)
+ elifhasattr(item,"__iter__"):
+ try:
+ # we try to conserve the iterable class if
+ # it accepts an iterator
+ returnitem.__class__(process_item(val)forvalinitem)
+ except(AttributeError,TypeError):
+ return[process_item(val)forvalinitem]
+ returnitem
+
+ defprocess_tree(item,parent):
+ """Recursive processor, building a parent-tree from iterable data"""
+ dtype=type(item)
+ ifdtypein(str,int,float,bool,bytes,SafeString):
+ returnitem
+ elif_IS_PACKED_DBOBJ(item):
+ # this must be checked before tuple
+ returnunpack_dbobj(item)
+ elifdtype==tuple:
+ returntuple(process_tree(val,item)forvalinitem)
+ elifdtype==list:
+ dat=_SaverList(_parent=parent)
+ dat._data.extend(process_tree(val,dat)forvalinitem)
+ returndat
+ elifdtype==dict:
+ dat=_SaverDict(_parent=parent)
+ dat._data.update(
+ (process_item(key),process_tree(val,dat))forkey,valinitem.items()
+ )
+ returndat
+ elifdtype==set:
+ dat=_SaverSet(_parent=parent)
+ dat._data.update(set(process_tree(val,dat)forvalinitem))
+ returndat
+ elifdtype==OrderedDict:
+ dat=_SaverOrderedDict(_parent=parent)
+ dat._data.update(
+ (process_item(key),process_tree(val,dat))forkey,valinitem.items()
+ )
+ returndat
+ elifdtype==deque:
+ dat=_SaverDeque(_parent=parent)
+ dat._data.extend(process_item(val)forvalinitem)
+ returndat
+ elifhasattr(item,"__iter__"):
+ try:
+ # we try to conserve the iterable class if it
+ # accepts an iterator
+ returnitem.__class__(process_tree(val,parent)forvalinitem)
+ except(AttributeError,TypeError):
+ dat=_SaverList(_parent=parent)
+ dat._data.extend(process_tree(val,dat)forvalinitem)
+ returndat
+ returnitem
+
+ ifdb_obj:
+ # convert lists, dicts and sets to their Saved* counterparts. It
+ # is only relevant if the "root" is an iterable of the right type.
+ dtype=type(data)
+ ifdtype==list:
+ dat=_SaverList(_db_obj=db_obj)
+ dat._data.extend(process_tree(val,dat)forvalindata)
+ returndat
+ elifdtype==dict:
+ dat=_SaverDict(_db_obj=db_obj)
+ dat._data.update(
+ (process_item(key),process_tree(val,dat))forkey,valindata.items()
+ )
+ returndat
+ elifdtype==set:
+ dat=_SaverSet(_db_obj=db_obj)
+ dat._data.update(process_tree(val,dat)forvalindata)
+ returndat
+ elifdtype==OrderedDict:
+ dat=_SaverOrderedDict(_db_obj=db_obj)
+ dat._data.update(
+ (process_item(key),process_tree(val,dat))forkey,valindata.items()
+ )
+ returndat
+ elifdtype==deque:
+ dat=_SaverDeque(_db_obj=db_obj)
+ dat._data.extend(process_item(val)forvalindata)
+ returndat
+ returnprocess_item(data)
+
+
+
[docs]defdo_pickle(data):
+ """Perform pickle to string"""
+ try:
+ returndumps(data,protocol=PICKLE_PROTOCOL)
+ exceptException:
+ logger.log_err(f"Could not pickle data for storage: {data}")
+ raise
+
+
+
[docs]defdo_unpickle(data):
+ """Retrieve pickle from pickled string"""
+ try:
+ returnloads(to_bytes(data))
+ exceptException:
+ logger.log_err(f"Could not unpickle data from storage: {data}")
+ raise
+
+
+
[docs]defdbserialize(data):
+ """Serialize to pickled form in one step"""
+ returndo_pickle(to_pickle(data))
+
+
+
[docs]defdbunserialize(data,db_obj=None):
+ """Un-serialize in one step. See from_pickle for help db_obj."""
+ returnfrom_pickle(do_unpickle(data),db_obj=db_obj)
+"""
+EvEditor (Evennia Line Editor)
+
+This implements an advanced line editor for editing longer texts
+in-game. The editor mimics the command mechanisms of the "VI" editor
+(a famous line-by-line editor) as far as reasonable.
+
+Features of the editor:
+
+ - undo/redo.
+ - edit/replace on any line of the buffer.
+ - search&replace text anywhere in buffer.
+ - formatting of buffer, or selection, to certain width + indentations.
+ - allow to echo the input or not, depending on your client.
+
+To use the editor, just import EvEditor from this module
+and initialize it:
+::
+
+ from evennia.utils.eveditor import EvEditor
+ EvEditor(caller, loadfunc=None, savefunc=None, quitfunc=None, key="", persistent=True)
+
+- `caller` is the user of the editor, the one to see all feedback.
+- `loadfunc(caller)` is called when the editor is first launched; the
+ return from this function is loaded as the starting buffer in the
+ editor.
+- `safefunc(caller, buffer)` is called with the current buffer when
+ saving in the editor. The function should return True/False depending
+ on if the saving was successful or not.
+- `quitfunc(caller)` is called when the editor exits. If this is given,
+ no automatic quit messages will be given.
+- `key` is an optional identifier for the editing session, to be
+ displayed in the editor.
+- `persistent` means the editor state will be saved to the database making it
+ survive a server reload. Note that using this mode, the load- save-
+ and quit-funcs must all be possible to pickle - notable unusable
+ callables are class methods and functions defined inside other
+ functions. With persistent=False, no such restriction exists.
+- `code` set to True activates features on the EvEditor to enter Python code.
+
+In addition, the EvEditor can be used to enter Python source code,
+and offers basic handling of indentation.
+
+----
+
+"""
+importre
+
+fromdjango.confimportsettings
+fromevenniaimportCommand,CmdSet
+fromevennia.utilsimportis_iter,fill,dedent,logger,justify,to_str,utils
+fromevennia.utils.ansiimportraw
+fromevennia.commandsimportcmdhandler
+
+# we use cmdhandler instead of evennia.syscmdkeys to
+# avoid some cases of loading before evennia init'd
+_CMD_NOMATCH=cmdhandler.CMD_NOMATCH
+_CMD_NOINPUT=cmdhandler.CMD_NOINPUT
+
+_RE_GROUP=re.compile(r"\".*?\"|\'.*?\'|\S*")
+_COMMAND_DEFAULT_CLASS=utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+# use NAWS in the future?
+_DEFAULT_WIDTH=settings.CLIENT_DEFAULT_WIDTH
+
+# -------------------------------------------------------------
+#
+# texts
+#
+# -------------------------------------------------------------
+
+_HELP_TEXT="""
+ <txt> - any non-command is appended to the end of the buffer.
+ : <l> - view buffer or only line(s) <l>
+ :: <l> - raw-view buffer or only line(s) <l>
+ ::: - escape - enter ':' as the only character on the line.
+ :h - this help.
+
+ :w - save the buffer (don't quit)
+ :wq - save buffer and quit
+ :q - quit (will be asked to save if buffer was changed)
+ :q! - quit without saving, no questions asked
+
+ :u - (undo) step backwards in undo history
+ :uu - (redo) step forward in undo history
+ :UU - reset all changes back to initial state
+
+ :dd <l> - delete last line or line(s) <l>
+ :dw <l> <w> - delete word or regex <w> in entire buffer or on line <l>
+ :DD - clear entire buffer
+
+ :y <l> - yank (copy) line(s) <l> to the copy buffer
+ :x <l> - cut line(s) <l> and store it in the copy buffer
+ :p <l> - put (paste) previously copied line(s) directly after <l>
+ :i <l> <txt> - insert new text <txt> at line <l>. Old line will move down
+ :r <l> <txt> - replace line <l> with text <txt>
+ :I <l> <txt> - insert text at the beginning of line <l>
+ :A <l> <txt> - append text after the end of line <l>
+
+ :s <l> <w> <txt> - search/replace word or regex <w> in buffer or on line <l>
+
+ :j <l> <w> - justify buffer or line <l>. <w> is f, c, l or r. Default f (full)
+ :f <l> - flood-fill entire buffer or line <l>: Equivalent to :j left
+ :fi <l> - indent entire buffer or line <l>
+ :fd <l> - de-indent entire buffer or line <l>
+
+ :echo - turn echoing of the input on/off (helpful for some clients)
+"""
+
+_HELP_LEGEND="""
+ Legend:
+ <l> - line number, like '5' or range, like '3:7'.
+ <w> - a single word, or multiple words with quotes around them.
+ <txt> - longer string, usually not needing quotes.
+"""
+
+_HELP_CODE="""
+ :! - Execute code buffer without saving
+ :< - Decrease the level of automatic indentation for the next lines
+ :> - Increase the level of automatic indentation for the next lines
+ := - Switch automatic indentation on/off
+""".lstrip(
+ "\n"
+)
+
+_ERROR_LOADFUNC="""
+{error}
+
+|rBuffer load function error. Could not load initial data.|n
+"""
+
+_ERROR_SAVEFUNC="""
+{error}
+
+|rSave function returned an error. Buffer not saved.|n
+"""
+
+_ERROR_NO_SAVEFUNC="|rNo save function defined. Buffer cannot be saved.|n"
+
+_MSG_SAVE_NO_CHANGE="No changes need saving"
+_DEFAULT_NO_QUITFUNC="Exited editor."
+
+_ERROR_QUITFUNC="""
+{error}
+
+|rQuit function gave an error. Skipping.|n
+"""
+
+_ERROR_PERSISTENT_SAVING="""
+{error}
+
+|rThe editor state could not be saved for persistent mode. Switching
+to non-persistent mode (which means the editor session won't survive
+an eventual server reload - so save often!)|n
+"""
+
+_TRACE_PERSISTENT_SAVING=(
+ "EvEditor persistent-mode error. Commonly, this is because one or "
+ "more of the EvEditor callbacks could not be pickled, for example "
+ "because it's a class method or is defined inside another function."
+)
+
+
+_MSG_NO_UNDO="Nothing to undo."
+_MSG_NO_REDO="Nothing to redo."
+_MSG_UNDO="Undid one step."
+_MSG_REDO="Redid one step."
+
+# -------------------------------------------------------------
+#
+# Handle yes/no quit question
+#
+# -------------------------------------------------------------
+
+
+
[docs]classCmdSaveYesNo(_COMMAND_DEFAULT_CLASS):
+ """
+ Save the editor state on quit. This catches
+ nomatches (defaults to Yes), and avoid saves only if
+ command was given specifically as "no" or "n".
+ """
+
+ key=_CMD_NOMATCH
+ aliases=_CMD_NOINPUT
+ locks="cmd:all()"
+ help_cateogory="LineEditor"
+
+
[docs]deffunc(self):
+ """Implement the yes/no choice."""
+ # this is only called from inside the lineeditor
+ # so caller.ndb._lineditor must be set.
+
+ self.caller.cmdset.remove(SaveYesNoCmdSet)
+ ifself.raw_string.strip().lower()in("no","n"):
+ # answered no
+ self.caller.msg(self.caller.ndb._eveditor.quit())
+ else:
+ # answered yes (default)
+ self.caller.ndb._eveditor.save_buffer()
+ self.caller.ndb._eveditor.quit()
+
+
+
[docs]classSaveYesNoCmdSet(CmdSet):
+ """Stores the yesno question"""
+
+ key="quitsave_yesno"
+ priority=150# override other cmdsets.
+ mergetype="Replace"
+
+
[docs]defparse(self):
+ """
+ Handles pre-parsing
+
+ Usage:
+ :cmd [li] [w] [txt]
+
+ Where all arguments are optional.
+
+ - li - line number (int), starting from 1. This could also
+ be a range given as <l>:<l>.
+ - w - word(s) (string), could be encased in quotes.
+ - txt - extra text (string), could be encased in quotes.
+
+ """
+
+ editor=self.caller.ndb._eveditor
+ ifnoteditor:
+ # this will completely replace the editor
+ _load_editor(self.caller)
+ editor=self.caller.ndb._eveditor
+ self.editor=editor
+
+ linebuffer=self.editor.get_buffer().split("\n")
+
+ nlines=len(linebuffer)
+
+ # The regular expression will split the line by whitespaces,
+ # stripping extra whitespaces, except if the text is
+ # surrounded by single- or double quotes, in which case they
+ # will be kept together and extra whitespace preserved. You
+ # can input quotes on the line by alternating single and
+ # double quotes.
+ arglist=[partforpartin_RE_GROUP.findall(self.args)ifpart]
+ temp=[]
+ forarginarglist:
+ # we want to clean the quotes, but only one type,
+ # in case we are nesting.
+ ifarg.startswith('"'):
+ arg.strip('"')
+ elifarg.startswith("'"):
+ arg.strip("'")
+ temp.append(arg)
+ arglist=temp
+
+ # A dumb split, without grouping quotes
+ words=self.args.split()
+
+ # current line number
+ cline=nlines-1
+
+ # the first argument could also be a range of line numbers, on the
+ # form <lstart>:<lend>. Either of the ends could be missing, to
+ # mean start/end of buffer respectively.
+
+ lstart,lend=cline,cline+1
+ linerange=False
+ ifarglistandarglist[0].count(":")==1:
+ part1,part2=arglist[0].split(":")
+ ifpart1andpart1.isdigit():
+ lstart=min(max(0,int(part1))-1,nlines)
+ linerange=True
+ ifpart2andpart2.isdigit():
+ lend=min(lstart+1,int(part2))+1
+ linerange=True
+ elifarglistandarglist[0].isdigit():
+ lstart=min(max(0,int(arglist[0])-1),nlines)
+ lend=lstart+1
+ linerange=True
+ iflinerange:
+ arglist=arglist[1:]
+
+ # nicer output formatting of the line range.
+ lstr=(
+ "line %i"%(lstart+1)
+ ifnotlinerangeorlstart+1==lend
+ else"lines %i-%i"%(lstart+1,lend)
+ )
+
+ # arg1 and arg2 is whatever arguments. Line numbers or -ranges are
+ # never included here.
+ args=" ".join(arglist)
+ arg1,arg2="",""
+ iflen(arglist)>1:
+ arg1,arg2=arglist[0]," ".join(arglist[1:])
+ else:
+ arg1=" ".join(arglist)
+
+ # store for use in func()
+
+ self.linebuffer=linebuffer
+ self.nlines=nlines
+ self.arglist=arglist
+ self.cline=cline
+ self.lstart=lstart
+ self.lend=lend
+ self.linerange=linerange
+ self.lstr=lstr
+ self.words=words
+ self.args=args
+ self.arg1=arg1
+ self.arg2=arg2
+
+
+def_load_editor(caller):
+ """
+ Load persistent editor from storage.
+ """
+ saved_options=caller.attributes.get("_eveditor_saved")
+ saved_buffer,saved_undo=caller.attributes.get("_eveditor_buffer_temp",(None,None))
+ unsaved=caller.attributes.get("_eveditor_unsaved",False)
+ indent=caller.attributes.get("_eveditor_indent",0)
+ ifsaved_options:
+ eveditor=EvEditor(caller,**saved_options[0])
+ ifsaved_buffer:
+ # we have to re-save the buffer data so we can handle subsequent restarts
+ caller.attributes.add("_eveditor_buffer_temp",(saved_buffer,saved_undo))
+ setattr(eveditor,"_buffer",saved_buffer)
+ setattr(eveditor,"_undo_buffer",saved_undo)
+ setattr(eveditor,"_undo_pos",len(saved_undo)-1)
+ setattr(eveditor,"_unsaved",unsaved)
+ setattr(eveditor,"_indent",indent)
+ forkey,valueinsaved_options[1].items():
+ setattr(eveditor,key,value)
+ else:
+ # something went wrong. Cleanup.
+ caller.cmdset.remove(EvEditorCmdSet)
+
+
+
[docs]classCmdLineInput(CmdEditorBase):
+ """
+ No command match - Inputs line of text into buffer.
+ """
+
+ key=_CMD_NOMATCH
+ aliases=_CMD_NOINPUT
+
+
[docs]deffunc(self):
+ """
+ Adds the line without any formatting changes.
+
+ If the editor handles code, it might add automatic
+ indentation.
+ """
+ caller=self.caller
+ editor=caller.ndb._eveditor
+ buf=editor.get_buffer()
+
+ # add a line of text to buffer
+ line=self.raw_string.strip("\r\n")
+ ifeditor._codefuncandeditor._indent>=0:
+ # if automatic indentation is active, add spaces
+ line=editor.deduce_indent(line,buf)
+ buf=lineifnotbufelsebuf+"\n%s"%line
+ self.editor.update_buffer(buf)
+ ifself.editor._echo_mode:
+ # need to do it here or we will be off one line
+ cline=len(self.editor.get_buffer().split("\n"))
+ ifeditor._codefunc:
+ # display the current level of identation
+ indent=editor._indent
+ ifindent<0:
+ indent="off"
+
+ self.caller.msg("|b%02i|||n (|g%s|n) %s"%(cline,indent,raw(line)))
+ else:
+ self.caller.msg("|b%02i|||n %s"%(cline,raw(self.args)))
[docs]classEvEditor(object):
+ """
+ This defines a line editor object. It creates all relevant commands
+ and tracks the current state of the buffer. It also cleans up after
+ itself.
+
+ """
+
+
[docs]def__init__(
+ self,
+ caller,
+ loadfunc=None,
+ savefunc=None,
+ quitfunc=None,
+ key="",
+ persistent=False,
+ codefunc=False,
+ ):
+ """
+ Launches a full in-game line editor, mimicking the functionality of VIM.
+
+ Args:
+ caller (Object): Who is using the editor.
+ loadfunc (callable, optional): This will be called as
+ `loadfunc(caller)` when the editor is first started. Its
+ return will be used as the editor's starting buffer.
+ savefunc (callable, optional): This will be called as
+ `savefunc(caller, buffer)` when the save-command is given and
+ is used to actually determine where/how result is saved.
+ It should return `True` if save was successful and also
+ handle any feedback to the user.
+ quitfunc (callable, optional): This will optionally be
+ called as `quitfunc(caller)` when the editor is
+ exited. If defined, it should handle all wanted feedback
+ to the user.
+ quitfunc_args (tuple, optional): Optional tuple of arguments to
+ supply to `quitfunc`.
+ key (str, optional): An optional key for naming this
+ session and make it unique from other editing sessions.
+ persistent (bool, optional): Make the editor survive a reboot. Note
+ that if this is set, all callables must be possible to pickle
+ codefunc (bool, optional): If given, will run the editor in code mode.
+ This will be called as `codefunc(caller, buf)`.
+
+ Notes:
+ In persistent mode, all the input callables (savefunc etc)
+ must be possible to be *pickled*, this excludes e.g.
+ callables that are class methods or functions defined
+ dynamically or as part of another function. In
+ non-persistent mode no such restrictions exist.
+
+
+
+ """
+ self._key=key
+ self._caller=caller
+ self._caller.ndb._eveditor=self
+ self._buffer=""
+ self._unsaved=False
+ self._persistent=persistent
+ self._indent=0
+
+ ifloadfunc:
+ self._loadfunc=loadfunc
+ else:
+ self._loadfunc=lambdacaller:self._buffer
+ self.load_buffer()
+ ifsavefunc:
+ self._savefunc=savefunc
+ else:
+ self._savefunc=lambdacaller,buffer:caller.msg(_ERROR_NO_SAVEFUNC)
+ ifquitfunc:
+ self._quitfunc=quitfunc
+ else:
+ self._quitfunc=lambdacaller:caller.msg(_DEFAULT_NO_QUITFUNC)
+ self._codefunc=codefunc
+
+ # store the original version
+ self._pristine_buffer=self._buffer
+ self._sep="-"
+
+ # undo operation buffer
+ self._undo_buffer=[self._buffer]
+ self._undo_pos=0
+ self._undo_max=20
+
+ # copy buffer
+ self._copy_buffer=[]
+
+ ifpersistent:
+ # save in tuple {kwargs, other options}
+ try:
+ caller.attributes.add(
+ "_eveditor_saved",
+ (
+ dict(
+ loadfunc=loadfunc,
+ savefunc=savefunc,
+ quitfunc=quitfunc,
+ codefunc=codefunc,
+ key=key,
+ persistent=persistent,
+ ),
+ dict(_pristine_buffer=self._pristine_buffer,_sep=self._sep),
+ ),
+ )
+ caller.attributes.add("_eveditor_buffer_temp",(self._buffer,self._undo_buffer))
+ caller.attributes.add("_eveditor_unsaved",False)
+ caller.attributes.add("_eveditor_indent",0)
+ exceptExceptionaserr:
+ caller.msg(_ERROR_PERSISTENT_SAVING.format(error=err))
+ logger.log_trace(_TRACE_PERSISTENT_SAVING)
+ persistent=False
+
+ # Create the commands we need
+ caller.cmdset.add(EvEditorCmdSet,permanent=persistent)
+
+ # echo inserted text back to caller
+ self._echo_mode=True
+
+ # show the buffer ui
+ self.display_buffer()
+
+
[docs]defload_buffer(self):
+ """
+ Load the buffer using the load function hook.
+ """
+ try:
+ self._buffer=self._loadfunc(self._caller)
+ ifnotisinstance(self._buffer,str):
+ self._buffer=to_str(self._buffer)
+ self._caller.msg("|rNote: input buffer was converted to a string.|n")
+ exceptExceptionase:
+ fromevennia.utilsimportlogger
+
+ logger.log_trace()
+ self._caller.msg(_ERROR_LOADFUNC.format(error=e))
+
+
[docs]defget_buffer(self):
+ """
+ Return:
+ buffer (str): The current buffer.
+
+ """
+ returnself._buffer
+
+
[docs]defupdate_buffer(self,buf):
+ """
+ This should be called when the buffer has been changed
+ somehow. It will handle unsaved flag and undo updating.
+
+ Args:
+ buf (str): The text to update the buffer with.
+
+ """
+ ifis_iter(buf):
+ buf="\n".join(buf)
+
+ ifbuf!=self._buffer:
+ self._buffer=buf
+ self.update_undo()
+ self._unsaved=True
+ ifself._persistent:
+ self._caller.attributes.add(
+ "_eveditor_buffer_temp",(self._buffer,self._undo_buffer)
+ )
+ self._caller.attributes.add("_eveditor_unsaved",True)
+ self._caller.attributes.add("_eveditor_indent",self._indent)
[docs]defsave_buffer(self):
+ """
+ Saves the content of the buffer.
+
+ """
+ ifself._unsavedorself._codefunc:
+ # always save code - this allows us to tie execution to
+ # saving if we want.
+ try:
+ ifself._savefunc(self._caller,self._buffer):
+ # Save codes should return a true value to indicate
+ # save worked. The saving function is responsible for
+ # any status messages.
+ self._unsaved=False
+ exceptExceptionase:
+ self._caller.msg(_ERROR_SAVEFUNC.format(error=e))
+ else:
+ self._caller.msg(_MSG_SAVE_NO_CHANGE)
+
+
[docs]defupdate_undo(self,step=None):
+ """
+ This updates the undo position.
+
+ Args:
+ step (int, optional): The amount of steps
+ to progress the undo position to. This
+ may be a negative value for undo and
+ a positive value for redo.
+
+ """
+ ifstepandstep<0:
+ # undo
+ ifself._undo_pos<=0:
+ self._caller.msg(_MSG_NO_UNDO)
+ else:
+ self._undo_pos=max(0,self._undo_pos+step)
+ self._buffer=self._undo_buffer[self._undo_pos]
+ self._caller.msg(_MSG_UNDO)
+ elifstepandstep>0:
+ # redo
+ ifself._undo_pos>=len(self._undo_buffer)-1orself._undo_pos+1>=self._undo_max:
+ self._caller.msg(_MSG_NO_REDO)
+ else:
+ self._undo_pos=min(
+ self._undo_pos+step,min(len(self._undo_buffer),self._undo_max)-1
+ )
+ self._buffer=self._undo_buffer[self._undo_pos]
+ self._caller.msg(_MSG_REDO)
+ ifnotself._undo_bufferor(
+ self._undo_bufferandself._buffer!=self._undo_buffer[self._undo_pos]
+ ):
+ # save undo state
+ self._undo_buffer=self._undo_buffer[:self._undo_pos+1]+[self._buffer]
+ self._undo_pos=len(self._undo_buffer)-1
+
+
[docs]defdisplay_buffer(self,buf=None,offset=0,linenums=True,options={"raw":False}):
+ """
+ This displays the line editor buffer, or selected parts of it.
+
+ Args:
+ buf (str, optional): The buffer or part of buffer to display.
+ offset (int, optional): If `buf` is set and is not the full buffer,
+ `offset` should define the actual starting line number, to
+ get the linenum display right.
+ linenums (bool, optional): Show line numbers in buffer.
+ options: raw (bool, optional): Tell protocol to not parse
+ formatting information.
+
+ """
+ ifbufisNone:
+ buf=self._buffer
+ ifis_iter(buf):
+ buf="\n".join(buf)
+
+ lines=buf.split("\n")
+ nlines=len(lines)
+ nwords=len(buf.split())
+ nchars=len(buf)
+
+ sep=self._sep
+ header=(
+ "|n"
+ +sep*10
+ +"Line Editor [%s]"%self._key
+ +sep*(_DEFAULT_WIDTH-24-len(self._key))
+ )
+ footer=(
+ "|n"
+ +sep*10
+ +"[l:%02i w:%03i c:%04i]"%(nlines,nwords,nchars)
+ +sep*12
+ +"(:h for help)"
+ +sep*(_DEFAULT_WIDTH-54)
+ )
+ iflinenums:
+ main="\n".join(
+ "|b%02i|||n %s"%(iline+1+offset,raw(line))
+ foriline,lineinenumerate(lines)
+ )
+ else:
+ main="\n".join([raw(line)forlineinlines])
+ string="%s\n%s\n%s"%(header,main,footer)
+ self._caller.msg(string,options=options)
+
+
[docs]defdisplay_help(self):
+ """
+ Shows the help entry for the editor.
+
+ """
+ string=self._sep*_DEFAULT_WIDTH+_HELP_TEXT
+ ifself._codefunc:
+ string+=_HELP_CODE
+ string+=_HELP_LEGEND+self._sep*_DEFAULT_WIDTH
+ self._caller.msg(string)
+
+
[docs]defdeduce_indent(self,line,buffer):
+ """
+ Try to deduce the level of indentation of the given line.
+
+ """
+ keywords={
+ "elif ":["if "],
+ "else:":["if ","try"],
+ "except":["try:"],
+ "finally:":["try:"],
+ }
+ opening_tags=("if ","try:","for ","while ")
+
+ # If the line begins by one of the given keywords
+ indent=self._indent
+ ifany(line.startswith(kw)forkwinkeywords.keys()):
+ # Get the keyword and matching begin tags
+ keyword=[kwforkwinkeywordsifline.startswith(kw)][0]
+ begin_tags=keywords[keyword]
+ forolineinreversed(buffer.splitlines()):
+ ifany(oline.lstrip(" ").startswith(tag)fortaginbegin_tags):
+ # This line begins with a begin tag, takes the identation
+ indent=(len(oline)-len(oline.lstrip(" ")))/4
+ break
+
+ self._indent=indent+1
+ ifself._persistent:
+ self._caller.attributes.add("_eveditor_indent",self._indent)
+ elifany(line.startswith(kw)forkwinopening_tags):
+ self._indent=indent+1
+ ifself._persistent:
+ self._caller.attributes.add("_eveditor_indent",self._indent)
+
+ line=" "*4*indent+line
+ returnline
+# coding=utf-8
+"""
+EvForm - a way to create advanced ASCII forms
+
+This is intended for creating advanced ASCII game forms, such as a
+large pretty character sheet or info document.
+
+The system works on the basis of a readin template that is given in a
+separate Python file imported into the handler. This file contains
+some optional settings and a string mapping out the form. The template
+has markers in it to denounce fields to fill. The markers map the
+absolute size of the field and will be filled with an `evtable.EvCell`
+object when displaying the form.
+
+Example of input file `testform.py`:
+::
+
+ FORMCHAR = "x"
+ TABLECHAR = "c"
+
+ FORM = '''
+ .------------------------------------------------.
+ | |
+ | Name: xxxxx1xxxxx Player: xxxxxxx2xxxxxxx |
+ | xxxxxxxxxxx |
+ | |
+ >----------------------------------------------<
+ | |
+ | Desc: xxxxxxxxxxx STR: x4x DEX: x5x |
+ | xxxxx3xxxxx INT: x6x STA: x7x |
+ | xxxxxxxxxxx LUC: x8x MAG: x9x |
+ | |
+ >----------------------------------------------<
+ | | |
+ | cccccccc | ccccccccccccccccccccccccccccccccccc |
+ | cccccccc | ccccccccccccccccccccccccccccccccccc |
+ | cccAcccc | ccccccccccccccccccccccccccccccccccc |
+ | cccccccc | ccccccccccccccccccccccccccccccccccc |
+ | cccccccc | cccccccccccccccccBccccccccccccccccc |
+ | | |
+ -------------------------------------------------
+
+The first line of the `FORM` string is ignored. The forms and table
+markers must mark out complete, unbroken rectangles, each containing
+one embedded single-character identifier (so the smallest element
+possible is a 3-character wide form). The identifier can be any
+character except for the `FORM_CHAR` and `TABLE_CHAR` and some of the
+common ASCII-art elements, like space, `_` `|` `*` etc (see
+`INVALID_FORMCHARS` in this module). Form Rectangles can have any size,
+but must be separated from each other by at least one other
+character's width.
+
+
+Use as follows:
+::
+
+ from evennia import EvForm, EvTable
+
+ # create a new form from the template
+ form = EvForm("path/to/testform.py")
+
+ (MudForm can also take a dictionary holding
+ the required keys FORMCHAR, TABLECHAR and FORM)
+
+ # add data to each tagged form cell
+ form.map(cells={1: "Tom the Bouncer",
+ 2: "Griatch",
+ 3: "A sturdy fellow",
+ 4: 12,
+ 5: 10,
+ 6: 5,
+ 7: 18,
+ 8: 10,
+ 9: 3})
+ # create the EvTables
+ tableA = EvTable("HP","MV","MP",
+ table=[["**"], ["*****"], ["***"]],
+ border="incols")
+ tableB = EvTable("Skill", "Value", "Exp",
+ table=[["Shooting", "Herbalism", "Smithing"],
+ [12,14,9],["550/1200", "990/1400", "205/900"]],
+ border="incols")
+ # add the tables to the proper ids in the form
+ form.map(tables={"A": tableA,
+ "B": tableB})
+
+ print(form)
+
+
+This produces the following result:
+::
+
+ .------------------------------------------------.
+ | |
+ | Name: Tom the Player: Griatch |
+ | Bouncer |
+ | |
+ >----------------------------------------------<
+ | |
+ | Desc: A sturdy STR: 12 DEX: 10 |
+ | fellow INT: 5 STA: 18 |
+ | LUC: 10 MAG: 3 |
+ | |
+ >----------------------------------------------<
+ | | |
+ | HP|MV|MP | Skill |Value |Exp |
+ | ~~+~~+~~ | ~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~~~~ |
+ | **|**|** | Shooting |12 |550/1200 |
+ | |**|* | Herbalism |14 |990/1400 |
+ | |* | | Smithing |9 |205/900 |
+ | | |
+ ------------------------------------------------
+
+
+The marked forms have been replaced with EvCells of text and with
+EvTables. The form can be updated by simply re-applying `form.map()`
+with the updated data.
+
+When working with the template ASCII file, you can use `form.reload()`
+to re-read the template and re-apply all existing mappings.
+
+Each component is restrained to the width and height specified by the
+template, so it will resize to fit (or crop text if the area is too
+small for it). If you try to fit a table into an area it cannot fit
+into (when including its borders and at least one line of text), the
+form will raise an error.
+
+----
+
+"""
+
+importre
+importcopy
+fromevennia.utils.evtableimportEvCell,EvTable
+fromevennia.utils.utilsimportall_from_module,to_str,is_iter
+fromevennia.utils.ansiimportANSIString
+
+# non-valid form-identifying characters (which can thus be
+# used as separators between forms without being detected
+# as an identifier). These should be listed in regex form.
+
+INVALID_FORMCHARS=r"\s\/\|\\\*\_\-\#\<\>\~\^\:\;\.\,"
+# if there is an ansi-escape (||) we have to replace this with ||| to make sure
+# to properly escape down the line
+_ANSI_ESCAPE=re.compile(r"\|\|")
+
+
+def_to_rect(lines):
+ """
+ Forces all lines to be as long as the longest
+
+ Args:
+ lines (list): list of `ANSIString`s
+
+ Returns:
+ (list): list of `ANSIString`s of
+ same length as the longest input line
+
+ """
+ maxl=max(len(line)forlineinlines)
+ return[line+" "*(maxl-len(line))forlineinlines]
+
+
+def_to_ansi(obj,regexable=False):
+ "convert to ANSIString"
+ ifisinstance(obj,ANSIString):
+ returnobj
+ elifisinstance(obj,str):
+ # since ansi will be parsed twice (here and in the normal ansi send), we have to
+ # escape the |-structure twice. TODO: This is tied to the default color-tag syntax
+ # which is not ideal for those wanting to replace/extend it ...
+ obj=_ANSI_ESCAPE.sub(r"||||",obj)
+ ifisinstance(obj,dict):
+ returndict((key,_to_ansi(value,regexable=regexable))forkey,valueinobj.items())
+ elifis_iter(obj):
+ return[_to_ansi(o)foroinobj]
+ else:
+ returnANSIString(obj,regexable=regexable)
+
+
+
[docs]classEvForm:
+ """
+ This object is instantiated with a text file and parses
+ it for rectangular form fields. It can then be fed a
+ mapping so as to populate the fields with fixed-width
+ EvCell or Tables.
+
+ """
+
+
[docs]def__init__(self,filename=None,cells=None,tables=None,form=None,**kwargs):
+ """
+ Initiate the form.
+
+ Keyword Args:
+ filename (str): Path to template file.
+ cells (dict): A dictionary mapping of `{id:text}`.
+ tables (dict): A dictionary mapping of `{id:EvTable}`.
+ form (dict): A dictionary of
+ `{"FORMCHAR":char, "TABLECHAR":char, "FORM":templatestring}`.
+ if this is given, filename is not read.
+ Notes:
+ Other kwargs are fed as options to the EvCells and EvTables
+ (see `evtable.EvCell` and `evtable.EvTable` for more info).
+
+ """
+ self.filename=filename
+ self.input_form_dict=form
+
+ self.cells_mapping=(
+ dict((to_str(key),value)forkey,valueincells.items())ifcellselse{}
+ )
+ self.tables_mapping=(
+ dict((to_str(key),value)forkey,valueintables.items())iftableselse{}
+ )
+
+ self.cellchar="x"
+ self.tablechar="c"
+
+ self.raw_form=[]
+ self.form=[]
+
+ # clean kwargs (these cannot be overridden)
+ kwargs.pop("enforce_size",None)
+ kwargs.pop("width",None)
+ kwargs.pop("height",None)
+ # table/cell options
+ self.options=kwargs
+
+ self.reload()
+
+ def_parse_rectangles(self,cellchar,tablechar,form,**kwargs):
+ """
+ Parse a form for rectangular formfields identified by formchar
+ enclosing an identifier.
+
+ """
+
+ # update options given at creation with new input - this
+ # allows e.g. self.map() to add custom settings for individual
+ # cells/tables
+ custom_options=copy.copy(self.options)
+ custom_options.update(kwargs)
+
+ nform=len(form)
+
+ mapping={}
+ cell_coords={}
+ table_coords={}
+
+ # Locate the identifier tags and the horizontal end coords for all forms
+ re_cellchar=re.compile(
+ r"%s+([^%s%s]+)%s+"%(cellchar,INVALID_FORMCHARS,cellchar,cellchar)
+ )
+ re_tablechar=re.compile(
+ r"%s+([^%s%s|+])%s+"%(tablechar,INVALID_FORMCHARS,tablechar,tablechar)
+ )
+ foriy,lineinenumerate(_to_ansi(form,regexable=True)):
+ # find cells
+ ix0=0
+ whileTrue:
+ match=re_cellchar.search(line,ix0)
+ ifmatch:
+ # get the width of the rectangle directly from the match
+ cell_coords[match.group(1)]=[iy,match.start(),match.end()]
+ ix0=match.end()
+ else:
+ break
+ # find tables
+ ix0=0
+ whileTrue:
+ match=re_tablechar.search(line,ix0)
+ ifmatch:
+ # get the width of the rectangle directly from the match
+ table_coords[match.group(1)]=[iy,match.start(),match.end()]
+ ix0=match.end()
+ else:
+ break
+
+ # get rectangles and assign EvCells
+ forkey,(iy,leftix,rightix)incell_coords.items():
+ # scan up to find top of rectangle
+ dy_up=0
+ ifiy>0:
+ foriinrange(1,iy):
+ ifall(form[iy-i][ix]==cellcharforixinrange(leftix,rightix)):
+ dy_up+=1
+ else:
+ break
+ # find bottom edge of rectangle
+ dy_down=0
+ ifiy<nform-1:
+ foriinrange(1,nform-iy-1):
+ ifall(form[iy+i][ix]==cellcharforixinrange(leftix,rightix)):
+ dy_down+=1
+ else:
+ break
+
+ # we have our rectangle. Calculate size of EvCell.
+ iyup=iy-dy_up
+ iydown=iy+dy_down
+ width=rightix-leftix
+ height=abs(iyup-iydown)+1
+
+ # we have all the coordinates we need. Create EvCell.
+ data=self.cells_mapping.get(key,"")
+ # if key == "1":
+
+ options={
+ "pad_left":0,
+ "pad_right":0,
+ "pad_top":0,
+ "pad_bottom":0,
+ "align":"l",
+ "valign":"t",
+ "enforce_size":True,
+ }
+ options.update(custom_options)
+ # if key=="4":
+
+ mapping[key]=(
+ iyup,
+ leftix,
+ width,
+ height,
+ EvCell(data,width=width,height=height,**options),
+ )
+
+ # get rectangles and assign Tables
+ forkey,(iy,leftix,rightix)intable_coords.items():
+
+ # scan up to find top of rectangle
+ dy_up=0
+ ifiy>0:
+ foriinrange(1,iy):
+ ifall(form[iy-i][ix]==tablecharforixinrange(leftix,rightix)):
+ dy_up+=1
+ else:
+ break
+ # find bottom edge of rectangle
+ dy_down=0
+ ifiy<nform-1:
+ foriinrange(1,nform-iy-1):
+ ifall(form[iy+i][ix]==tablecharforixinrange(leftix,rightix)):
+ dy_down+=1
+ else:
+ break
+
+ # we have our rectangle. Calculate size of Table.
+ iyup=iy-dy_up
+ iydown=iy+dy_down
+ width=rightix-leftix
+ height=abs(iyup-iydown)+1
+
+ # we have all the coordinates we need. Create Table.
+ table=self.tables_mapping.get(key,None)
+
+ options={
+ "pad_left":0,
+ "pad_right":0,
+ "pad_top":0,
+ "pad_bottom":0,
+ "align":"l",
+ "valign":"t",
+ "enforce_size":True,
+ }
+ options.update(custom_options)
+
+ iftable:
+ table.reformat(width=width,height=height,**options)
+ else:
+ table=EvTable(width=width,height=height,**options)
+ mapping[key]=(iyup,leftix,width,height,table)
+
+ returnmapping
+
+ def_populate_form(self,raw_form,mapping):
+ """
+ Insert cell contents into form at given locations
+
+ """
+ form=copy.copy(raw_form)
+ forkey,(iy0,ix0,width,height,cell_or_table)inmapping.items():
+ # rect is a list of <height> lines, each <width> wide
+ rect=cell_or_table.get()
+ foril,rectlineinenumerate(rect):
+ formline=form[iy0+il]
+ # insert new content, replacing old
+ form[iy0+il]=formline[:ix0]+rectline+formline[ix0+width:]
+ returnform
+
+
[docs]defmap(self,cells=None,tables=None,**kwargs):
+ """
+ Add mapping for form.
+
+ Args:
+ cells (dict): A dictionary of {identifier:celltext}
+ tables (dict): A dictionary of {identifier:table}
+
+ Notes:
+ kwargs will be forwarded to tables/cells. See
+ `evtable.EvCell` and `evtable.EvTable` for info.
+
+ """
+ # clean kwargs (these cannot be overridden)
+ kwargs.pop("enforce_size",None)
+ kwargs.pop("width",None)
+ kwargs.pop("height",None)
+
+ new_cells=dict((to_str(key),value)forkey,valueincells.items())ifcellselse{}
+ new_tables=dict((to_str(key),value)forkey,valueintables.items())iftableselse{}
+
+ self.cells_mapping.update(new_cells)
+ self.tables_mapping.update(new_tables)
+ self.reload()
+
+
[docs]defreload(self,filename=None,form=None,**kwargs):
+ """
+ Creates the form from a stored file name.
+
+ Args:
+ filename (str): The file to read from.
+ form (dict): A mapping for the form.
+
+ Notes:
+ Kwargs are passed through to Cel creation.
+
+ """
+ # clean kwargs (these cannot be overridden)
+ kwargs.pop("enforce_size",None)
+ kwargs.pop("width",None)
+ kwargs.pop("height",None)
+
+ ifformorself.input_form_dict:
+ datadict=formifformelseself.input_form_dict
+ self.input_form_dict=datadict
+ eliffilenameorself.filename:
+ filename=filenameiffilenameelseself.filename
+ datadict=all_from_module(filename)
+ self.filename=filename
+ else:
+ datadict={}
+
+ cellchar=to_str(datadict.get("FORMCHAR","x"))
+ self.cellchar=to_str(cellchar[0]iflen(cellchar)>1elsecellchar)
+ tablechar=datadict.get("TABLECHAR","c")
+ self.tablechar=tablechar[0]iflen(tablechar)>1elsetablechar
+
+ # split into a list of list of lines. Form can be indexed with form[iy][ix]
+ raw_form=_to_ansi(datadict.get("FORM","").split("\n"))
+ self.raw_form=_to_rect(raw_form)
+
+ # strip first line
+ self.raw_form=self.raw_form[1:]ifself.raw_formelseself.raw_form
+
+ self.options.update(kwargs)
+
+ # parse and replace
+ self.mapping=self._parse_rectangles(
+ self.cellchar,self.tablechar,self.raw_form,**kwargs
+ )
+ self.form=self._populate_form(self.raw_form,self.mapping)
+
+ def__str__(self):
+ "Prints the form"
+ returnstr(ANSIString("\n").join([lineforlineinself.form]))
+"""
+The EvMenu is a full in-game menu system for Evennia.
+
+To start the menu, just import the EvMenu class from this module.
+
+Example usage:
+::
+
+ from evennia.utils.evmenu import EvMenu
+
+ EvMenu(caller, menu_module_path,
+ startnode="node1",
+ cmdset_mergetype="Replace", cmdset_priority=1,
+ auto_quit=True, cmd_on_exit="look", persistent=True)
+
+Where `caller` is the Object to use the menu on - it will get a new
+cmdset while using the Menu. The `menu_module_path` is the python path
+to a python module containing function definitions. By adjusting the
+keyword options of the Menu() initialization call you can start the
+menu at different places in the menu definition file, adjust if the
+menu command should overload the normal commands or not, etc.
+
+The `persistent` keyword will make the menu survive a server reboot.
+It is `False` by default. Note that if using persistent mode, every
+node and callback in the menu must be possible to be *pickled*, this
+excludes e.g. callables that are class methods or functions defined
+dynamically or as part of another function. In non-persistent mode
+no such restrictions exist.
+
+The menu is defined in a module (this can be the same module as the
+command definition too) with function definitions:
+::
+
+ def node1(caller):
+ # (this is the start node if called like above)
+ # code
+ return text, options
+
+ def node_with_other_name(caller, input_string):
+ # code
+ return text, options
+
+ def another_node(caller, input_string, **kwargs):
+ # code
+ return text, options
+
+Where `caller` is the object using the menu and input_string is the
+command entered by the user on the *previous* node (the command
+entered to get to this node). The node function code will only be
+executed once per node-visit and the system will accept nodes with
+both one or two arguments interchangeably. It also accepts nodes
+that takes `**kwargs`.
+
+The menu tree itself is available on the caller as
+`caller.ndb._evmenu`. This makes it a convenient place to store
+temporary state variables between nodes, since this NAttribute is
+deleted when the menu is exited.
+
+The return values must be given in the above order, but each can be
+returned as None as well. If the options are returned as None, the
+menu is immediately exited and the default "look" command is called.
+
+- `text` (str, tuple or None): Text shown at this node. If a tuple, the
+ second element in the tuple is a help text to display at this
+ node when the user enters the menu help command there.
+- `options` (tuple, dict or None): If `None`, this exits the menu.
+ If a single dict, this is a single-option node. If a tuple,
+ it should be a tuple of option dictionaries. Option dicts have
+ the following keys:
+
+ - `key` (str or tuple, optional): What to enter to choose this option.
+ If a tuple, it must be a tuple of strings, where the first string is the
+ key which will be shown to the user and the others are aliases.
+ If unset, the options' number will be used. The special key `_default`
+ marks this option as the default fallback when no other option matches
+ the user input. There can only be one `_default` option per node. It
+ will not be displayed in the list.
+ - `desc` (str, optional): This describes what choosing the option will do.
+ - `goto` (str, tuple or callable): If string, should be the name of node to go to
+ when this option is selected. If a callable, it has the signature
+ `callable(caller[,raw_input][,**kwargs])`. If a tuple, the first element
+ is the callable and the second is a dict with the kwargs to pass to
+ the callable. Those kwargs will also be passed into the next node if possible.
+ Such a callable should return either a str or a (str, dict), where the
+ string is the name of the next node to go to and the dict is the new,
+ (possibly modified) kwarg to pass into the next node. If the callable returns
+ None or the empty string, the current node will be revisited.
+ - `exec` (str, callable or tuple, optional): This takes the same input as `goto` above
+ and runs before it. If given a node name, the node will be executed but will not
+ be considered the next node. If node/callback returns str or (str, dict), these will
+ replace the `goto` step (`goto` callbacks will not fire), with the string being the
+ next node name and the optional dict acting as the kwargs-input for the next node.
+ If an exec callable returns `None`, the current node is re-run.
+
+If key is not given, the option will automatically be identified by
+its number 1..N.
+
+Example:
+::
+
+ # in menu_module.py
+
+ def node1(caller):
+ text = ("This is a node text",
+ "This is help text for this node")
+ options = ({"key": "testing",
+ "desc": "Select this to go to node 2",
+ "goto": ("node2", {"foo": "bar"}),
+ "exec": "callback1"},
+ {"desc": "Go to node 3.",
+ "goto": "node3"})
+ return text, options
+
+ def callback1(caller):
+ # this is called when choosing the "testing" option in node1
+ # (before going to node2). If it returned a string, say 'node3',
+ # then the next node would be node3 instead of node2 as specified
+ # by the normal 'goto' option key above.
+ caller.msg("Callback called!")
+
+ def node2(caller, **kwargs):
+ text = '''
+ This is node 2. It only allows you to go back
+ to the original node1. This extra indent will
+ be stripped. We don't include a help text but
+ here are the variables passed to us: {}
+ '''.format(kwargs)
+ options = {"goto": "node1"}
+ return text, options
+
+ def node3(caller):
+ text = "This ends the menu since there are no options."
+ return text, None
+
+When starting this menu with `Menu(caller, "path.to.menu_module")`,
+the first node will look something like this:
+::
+
+ This is a node text
+ ______________________________________
+
+ testing: Select this to go to node 2
+ 2: Go to node 3
+
+Where you can both enter "testing" and "1" to select the first option.
+If the client supports MXP, they may also mouse-click on "testing" to
+do the same. When making this selection, a function "callback1" in the
+same Using `help` will show the help text, otherwise a list of
+available commands while in menu mode.
+
+The menu tree is exited either by using the in-menu quit command or by
+reaching a node without any options.
+
+
+For a menu demo, import CmdTestMenu from this module and add it to
+your default cmdset. Run it with this module, like `testmenu
+evennia.utils.evmenu`.
+
+
+## Menu generation from template string
+
+In evmenu.py is a helper function `parse_menu_template` that parses a
+template-string and outputs a menu-tree dictionary suitable to pass into
+EvMenu:
+::
+
+ menutree = evmenu.parse_menu_template(caller, menu_template, goto_callables)
+ EvMenu(caller, menutree)
+
+For maximum flexibility you can inject normally-created nodes in the menu tree
+before passing it to EvMenu. If that's not needed, you can also create a menu
+in one step with:
+::
+
+ evmenu.template2menu(caller, menu_template, goto_callables)
+
+The `goto_callables` is a mapping `{"funcname": callable, ...}`, where each
+callable must be a module-global function on the form
+`funcname(caller, raw_string, **kwargs)` (like any goto-callable). The
+`menu_template` is a multi-line string on the following form:
+::
+
+ ## node start
+
+ This is the text of the start node.
+ The text area can have multiple lines, line breaks etc.
+
+ Each option below is one of these forms
+ key: desc -> gotostr_or_func
+ key: gotostr_or_func
+ >: gotostr_or_func
+ > glob/regex: gotostr_or_func
+
+ ## options
+
+ # comments are only allowed from beginning of line.
+ # Indenting is not necessary, but good for readability
+
+ 1: Option number 1 -> node1
+ 2: Option number 2 -> node2
+ next: This steps next -> go_back()
+ # the -> can be ignored if there is no desc
+ back: go_back(from_node=start)
+ abort: abort
+
+ ## node node1
+
+ Text for Node1. Enter a message!
+ <return> to go back.
+
+ ## options
+
+ # Starting the option-line with >
+ # allows to perform different actions depending on
+ # what is inserted.
+
+ # this catches everything starting with foo
+ > foo*: handle_foo_message()
+
+ # regex are also allowed (this catches number inputs)
+ > [0-9]+?: handle_numbers()
+
+ # this catches the empty return
+ >: start
+
+ # this catches everything else
+ > *: handle_message(from_node=node1)
+
+ ## node node2
+
+ Text for Node2. Just go back.
+
+ ## options
+
+ >: start
+
+ # node abort
+
+ This exits the menu since there is no `## options` section.
+
+Each menu node is defined by a `# node <name>` containing the text of the node,
+followed by `## options` Also `## NODE` and `## OPTIONS` work. No python code
+logics is allowed in the template, this code is not evaluated but parsed. More
+advanced dynamic usage requires a full node-function (which can be added to the
+generated dict, as said).
+
+Adding `(..)` to a goto treats it as a callable and it must then be included in
+the `goto_callable` mapping. Only named keywords (or no args at all) are
+allowed, these will be added to the `**kwargs` going into the callable. Quoting
+strings is only needed if wanting to pass strippable spaces, otherwise the
+key:values will be converted to strings/numbers with literal_eval before passed
+into the callable.
+
+The "> " option takes a glob or regex to perform different actions depending on user
+input. Make sure to sort these in increasing order of generality since they
+will be tested in sequence.
+
+----
+
+"""
+
+importre
+importinspect
+
+fromastimportliteral_eval
+fromfnmatchimportfnmatch
+
+frominspectimportisfunction,getargspec
+fromdjango.confimportsettings
+fromevenniaimportCommand,CmdSet
+fromevennia.utilsimportlogger
+fromevennia.utils.evtableimportEvTable
+fromevennia.utils.ansiimportstrip_ansi
+fromevennia.utils.utilsimportmod_import,make_iter,pad,to_str,m_len,is_iter,dedent,crop
+fromevennia.commandsimportcmdhandler
+
+# i18n
+fromdjango.utils.translationimportgettextas_
+
+# read from protocol NAWS later?
+_MAX_TEXT_WIDTH=settings.CLIENT_DEFAULT_WIDTH
+
+# we use cmdhandler instead of evennia.syscmdkeys to
+# avoid some cases of loading before evennia init'd
+_CMD_NOMATCH=cmdhandler.CMD_NOMATCH
+_CMD_NOINPUT=cmdhandler.CMD_NOINPUT
+
+# Return messages
+
+
+_ERR_NOT_IMPLEMENTED=_(
+ "Menu node '{nodename}' is either not implemented or caused an error. "
+ "Make another choice or try 'q' to abort."
+)
+_ERR_GENERAL=_("Error in menu node '{nodename}'.")
+_ERR_NO_OPTION_DESC=_("No description.")
+_HELP_FULL=_("Commands: <menu option>, help, quit")
+_HELP_NO_QUIT=_("Commands: <menu option>, help")
+_HELP_NO_OPTIONS=_("Commands: help, quit")
+_HELP_NO_OPTIONS_NO_QUIT=_("Commands: help")
+_HELP_NO_OPTION_MATCH=_("Choose an option or try 'help'.")
+
+_ERROR_PERSISTENT_SAVING="""
+{error}
+
+|rThe menu state could not be saved for persistent mode. Switching
+to non-persistent mode (which means the menu session won't survive
+an eventual server reload).|n
+"""
+
+_TRACE_PERSISTENT_SAVING=(
+ "EvMenu persistent-mode error. Commonly, this is because one or "
+ "more of the EvEditor callbacks could not be pickled, for example "
+ "because it's a class method or is defined inside another function."
+)
+
+
+
[docs]classEvMenuError(RuntimeError):
+ """
+ Error raised by menu when facing internal errors.
+
+ """
+
+ pass
+
+
+
[docs]classEvMenuGotoAbortMessage(RuntimeError):
+ """
+ This can be raised by a goto-callable to abort the goto flow. The message
+ stored with the executable will be sent to the caller who will remain on
+ the current node. This can be used to pass single-line returns without
+ re-running the entire node with text and options.
+
+ Example:
+ raise EvMenuGotoMessage("That makes no sense.")
+
+ """
+
+
+# -------------------------------------------------------------
+#
+# Menu command and command set
+#
+# -------------------------------------------------------------
+
+
+
[docs]defget_help(self):
+ return"Menu commands are explained within the menu."
+
+
[docs]deffunc(self):
+ """
+ Implement all menu commands.
+ """
+
+ def_restore(caller):
+ # check if there is a saved menu available.
+ # this will re-start a completely new evmenu call.
+ saved_options=caller.attributes.get("_menutree_saved")
+ ifsaved_options:
+ startnode_tuple=caller.attributes.get("_menutree_saved_startnode")
+ try:
+ startnode,startnode_input=startnode_tuple
+ exceptValueError:# old form of startnode store
+ startnode,startnode_input=startnode_tuple,""
+ ifstartnode:
+ saved_options[2]["startnode"]=startnode
+ saved_options[2]["startnode_input"]=startnode_input
+ MenuClass=saved_options[0]
+ # this will create a completely new menu call
+ MenuClass(caller,*saved_options[1],**saved_options[2])
+ returnTrue
+ returnNone
+
+ caller=self.caller
+ # we store Session on the menu since this can be hard to
+ # get in multisession environments if caller is an Account.
+ menu=caller.ndb._evmenu
+ ifnotmenu:
+ if_restore(caller):
+ return
+ orig_caller=caller
+ caller=caller.accountifhasattr(caller,"account")elseNone
+ menu=caller.ndb._evmenuifcallerelseNone
+ ifnotmenu:
+ ifcallerand_restore(caller):
+ return
+ caller=self.session
+ menu=caller.ndb._evmenu
+ ifnotmenu:
+ # can't restore from a session
+ err="Menu object not found as %s.ndb._evmenu!"%orig_caller
+ orig_caller.msg(
+ err
+ )# don't give the session as a kwarg here, direct to original
+ raiseEvMenuError(err)
+ # we must do this after the caller with the menu has been correctly identified since it
+ # can be either Account, Object or Session (in the latter case this info will be superfluous).
+ caller.ndb._evmenu._session=self.session
+ # we have a menu, use it.
+ menu.parse_input(self.raw_string)
+
+
+
[docs]classEvMenuCmdSet(CmdSet):
+ """
+ The Menu cmdset replaces the current cmdset.
+
+ """
+
+ key="menu_cmdset"
+ priority=1
+ mergetype="Replace"
+ no_objs=True
+ no_exits=True
+ no_channels=False
+
+
[docs]defat_cmdset_creation(self):
+ """
+ Called when creating the set.
+ """
+ self.add(CmdEvMenuNode())
+
+
+# ------------------------------------------------------------
+#
+# Menu main class
+#
+# -------------------------------------------------------------
+
+
+
[docs]classEvMenu:
+ """
+ This object represents an operational menu. It is initialized from
+ a menufile.py instruction.
+
+ """
+
+ # convenient helpers for easy overloading
+ node_border_char="_"
+
+
[docs]def__init__(
+ self,
+ caller,
+ menudata,
+ startnode="start",
+ cmdset_mergetype="Replace",
+ cmdset_priority=1,
+ auto_quit=True,
+ auto_look=True,
+ auto_help=True,
+ cmd_on_exit="look",
+ persistent=False,
+ startnode_input="",
+ session=None,
+ debug=False,
+ **kwargs,
+ ):
+ """
+ Initialize the menu tree and start the caller onto the first node.
+
+ Args:
+ caller (Object, Account or Session): The user of the menu.
+ menudata (str, module or dict): The full or relative path to the module
+ holding the menu tree data. All global functions in this module
+ whose name doesn't start with '_ ' will be parsed as menu nodes.
+ Also the module itself is accepted as input. Finally, a dictionary
+ menu tree can be given directly. This must then be a mapping
+ `{"nodekey":callable,...}` where `callable` must be called as
+ and return the data expected of a menu node. This allows for
+ dynamic menu creation.
+ startnode (str, optional): The starting node name in the menufile.
+ cmdset_mergetype (str, optional): 'Replace' (default) means the menu
+ commands will be exclusive - no other normal commands will
+ be usable while the user is in the menu. 'Union' means the
+ menu commands will be integrated with the existing commands
+ (it will merge with `merge_priority`), if so, make sure that
+ the menu's command names don't collide with existing commands
+ in an unexpected way. Also the CMD_NOMATCH and CMD_NOINPUT will
+ be overloaded by the menu cmdset. Other cmdser mergetypes
+ has little purpose for the menu.
+ cmdset_priority (int, optional): The merge priority for the
+ menu command set. The default (1) is usually enough for most
+ types of menus.
+ auto_quit (bool, optional): Allow user to use "q", "quit" or
+ "exit" to leave the menu at any point. Recommended during
+ development!
+ auto_look (bool, optional): Automatically make "looK" or "l" to
+ re-show the last node. Turning this off means you have to handle
+ re-showing nodes yourself, but may be useful if you need to
+ use "l" for some other purpose.
+ auto_help (bool, optional): Automatically make "help" or "h" show
+ the current help entry for the node. If turned off, eventual
+ help must be handled manually, but it may be useful if you
+ need 'h' for some other purpose, for example.
+ cmd_on_exit (callable, str or None, optional): When exiting the menu
+ (either by reaching a node with no options or by using the
+ in-built quit command (activated with `allow_quit`), this
+ callback function or command string will be executed.
+ The callback function takes two parameters, the caller then the
+ EvMenu object. This is called after cleanup is complete.
+ Set to None to not call any command.
+ persistent (bool, optional): Make the Menu persistent (i.e. it will
+ survive a reload. This will make the Menu cmdset persistent. Use
+ with caution - if your menu is buggy you may end up in a state
+ you can't get out of! Also note that persistent mode requires
+ that all formatters, menu nodes and callables are possible to
+ *pickle*. When the server is reloaded, the latest node shown will be completely
+ re-run with the same input arguments - so be careful if you are counting
+ up some persistent counter or similar - the counter may be run twice if
+ reload happens on the node that does that. Note that if `debug` is True,
+ this setting is ignored and assumed to be False.
+ startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if
+ a user input text from a fictional previous node. If including the dict, this will
+ be passed as **kwargs to that node. When the server reloads,
+ the latest visited node will be re-run as `node(caller, raw_string, **kwargs)`.
+ session (Session, optional): This is useful when calling EvMenu from an account
+ in multisession mode > 2. Note that this session only really relevant
+ for the very first display of the first node - after that, EvMenu itself
+ will keep the session updated from the command input. So a persistent
+ menu will *not* be using this same session anymore after a reload.
+ debug (bool, optional): If set, the 'menudebug' command will be made available
+ by default in all nodes of the menu. This will print out the current state of
+ the menu. Deactivate for production use! When the debug flag is active, the
+ `persistent` flag is deactivated.
+
+ Keyword Args:
+ any (any): All kwargs will become initialization variables on `caller.ndb._evmenu`,
+ to be available at run.
+
+ Raises:
+ EvMenuError: If the start/end node is not found in menu tree.
+
+ Notes:
+ While running, the menu is stored on the caller as `caller.ndb._evmenu`. Also
+ the current Session (from the Command, so this is still valid in multisession
+ environments) is available through `caller.ndb._evmenu._session`. The `_evmenu`
+ property is a good one for storing intermediary data on between nodes since it
+ will be automatically deleted when the menu closes.
+
+ In persistent mode, all nodes, formatters and callbacks in the menu must be
+ possible to be *pickled*, this excludes e.g. callables that are class methods
+ or functions defined dynamically or as part of another function. In
+ non-persistent mode no such restrictions exist.
+
+ """
+ self._startnode=startnode
+ self._menutree=self._parse_menudata(menudata)
+ self._persistent=persistentifnotdebugelseFalse
+ self._quitting=False
+
+ ifstartnodenotinself._menutree:
+ raiseEvMenuError("Start node '%s' not in menu tree!"%startnode)
+
+ # public variables made available to the command
+
+ self.caller=caller
+
+ # track EvMenu kwargs
+ self.auto_quit=auto_quit
+ self.auto_look=auto_look
+ self.auto_help=auto_help
+ self.debug_mode=debug
+ self._session=session
+ ifisinstance(cmd_on_exit,str):
+ # At this point menu._session will have been replaced by the
+ # menu command to the actual session calling.
+ self.cmd_on_exit=lambdacaller,menu:caller.execute_cmd(
+ cmd_on_exit,session=menu._session
+ )
+ elifcallable(cmd_on_exit):
+ self.cmd_on_exit=cmd_on_exit
+ else:
+ self.cmd_on_exit=None
+ # current menu state
+ self.default=None
+ self.nodetext=None
+ self.helptext=None
+ self.options=None
+ self.nodename=None
+ self.node_kwargs={}
+
+ # used for testing
+ self.test_options={}
+ self.test_nodetext=""
+
+ # assign kwargs as initialization vars on ourselves.
+ reserved_clash=set(
+ (
+ "_startnode",
+ "_menutree",
+ "_session",
+ "_persistent",
+ "cmd_on_exit",
+ "default",
+ "nodetext",
+ "helptext",
+ "options",
+ "cmdset_mergetype",
+ "auto_quit",
+ )
+ ).intersection(set(kwargs.keys()))
+ ifreserved_clash:
+ raiseRuntimeError(
+ f"One or more of the EvMenu `**kwargs` ({list(reserved_clash)}) is reserved by EvMenu for internal use."
+ )
+ forkey,valinkwargs.items():
+ setattr(self,key,val)
+
+ ifself.caller.ndb._evmenu:
+ # an evmenu already exists - we try to close it cleanly. Note that this will
+ # not fire the previous menu's end node.
+ try:
+ self.caller.ndb._evmenu.close_menu()
+ exceptException:
+ pass
+
+ # store ourself on the object
+ self.caller.ndb._evmenu=self
+
+ # DEPRECATED - for backwards-compatibility
+ self.caller.ndb._menutree=self
+
+ ifpersistent:
+ # save the menu to the database
+ calldict={
+ "startnode":startnode,
+ "cmdset_mergetype":cmdset_mergetype,
+ "cmdset_priority":cmdset_priority,
+ "auto_quit":auto_quit,
+ "auto_look":auto_look,
+ "auto_help":auto_help,
+ "cmd_on_exit":cmd_on_exit,
+ "persistent":persistent,
+ }
+ calldict.update(kwargs)
+ try:
+ caller.attributes.add("_menutree_saved",(self.__class__,(menudata,),calldict))
+ caller.attributes.add("_menutree_saved_startnode",(startnode,startnode_input))
+ exceptExceptionaserr:
+ self.msg(_ERROR_PERSISTENT_SAVING.format(error=err))
+ logger.log_trace(_TRACE_PERSISTENT_SAVING)
+ persistent=False
+
+ # set up the menu command on the caller
+ menu_cmdset=EvMenuCmdSet()
+ menu_cmdset.mergetype=str(cmdset_mergetype).lower().capitalize()or"Replace"
+ menu_cmdset.priority=int(cmdset_priority)
+ self.caller.cmdset.add(menu_cmdset,permanent=persistent)
+
+ reserved_startnode_kwargs=set(("nodename","raw_string"))
+ startnode_kwargs={}
+ ifisinstance(startnode_input,(tuple,list))andlen(startnode_input)>1:
+ startnode_input,startnode_kwargs=startnode_input[:2]
+ ifnotisinstance(startnode_kwargs,dict):
+ raiseEvMenuError("startnode_input must be either a str or a tuple (str, dict).")
+ clashing_kwargs=reserved_startnode_kwargs.intersection(set(startnode_kwargs.keys()))
+ ifclashing_kwargs:
+ raiseRuntimeError(
+ f"Evmenu startnode_inputs includes kwargs {tuple(clashing_kwargs)} that "
+ "clashes with EvMenu's internal usage."
+ )
+
+ # start the menu
+ self.goto(self._startnode,startnode_input,**startnode_kwargs)
+
+ def_parse_menudata(self,menudata):
+ """
+ Parse a menufile for node functions and store in dictionary
+ map. Alternatively, accept a pre-made mapping dictionary of
+ node functions.
+
+ Args:
+ menudata (str, module or dict): The python.path to the menufile,
+ or the python module itself. If a dict, this should be a
+ mapping nodename:callable, where the callable must match
+ the criteria for a menu node.
+
+ Returns:
+ menutree (dict): A {nodekey: func}
+
+ """
+ ifisinstance(menudata,dict):
+ # This is assumed to be a pre-loaded menu tree.
+ returnmenudata
+ else:
+ # a python path of a module
+ module=mod_import(menudata)
+ returndict(
+ (key,func)
+ forkey,funcinmodule.__dict__.items()
+ ifisfunction(func)andnotkey.startswith("_")
+ )
+
+ def_format_node(self,nodetext,optionlist):
+ """
+ Format the node text + option section
+
+ Args:
+ nodetext (str): The node text
+ optionlist (list): List of (key, desc) pairs.
+
+ Returns:
+ string (str): The options section, including
+ all needed spaces.
+
+ Notes:
+ This will adjust the columns of the options, first to use
+ a maxiumum of 4 rows (expanding in columns), then gradually
+ growing to make use of the screen space.
+
+ """
+
+ # handle the node text
+ nodetext=self.nodetext_formatter(nodetext)
+
+ # handle the options
+ optionstext=self.options_formatter(optionlist)
+
+ # format the entire node
+ returnself.node_formatter(nodetext,optionstext)
+
+ def_safe_call(self,callback,raw_string,**kwargs):
+ """
+ Call a node-like callable, with a variable number of raw_string, *args, **kwargs, all of
+ which should work also if not present (only `caller` is always required). Return its result.
+
+ Viable node-like callable forms:
+ ::
+
+ _callname(caller)
+ _callname(caller, raw_string)
+ _callname(caller, **kwargs)
+ _callname(caller, raw_string, **kwargs)
+
+ If this is a node:
+
+ - `caller` is the one using the menu.
+ - `raw_string` is the users exact input on the *previous* node.
+ - `**kwargs` is either passed through the previous node or returned
+ along with the node name from the goto-callable leading to this node.
+
+ If this is a goto-callable:
+
+ - `caller` is the one using the menu.
+ - `raw_string` is the user's exact input when chosing the option that triggered
+ this goto-callable.
+ - `**kwargs` is any extra dict passed to the callable in the option
+ definition, or (if no explit kwarg was given to the callable) the
+ previous node's kwarg, if any.
+
+ """
+ try:
+ try:
+ nargs=len(getargspec(callback).args)
+ exceptTypeError:
+ raiseEvMenuError("Callable {} doesn't accept any arguments!".format(callback))
+ supports_kwargs=bool(getargspec(callback).keywords)
+ ifnargs<=0:
+ raiseEvMenuError("Callable {} doesn't accept any arguments!".format(callback))
+
+ ifsupports_kwargs:
+ ifnargs>1:
+ ret=callback(self.caller,raw_string,**kwargs)
+ # callback accepting raw_string, **kwargs
+ else:
+ # callback accepting **kwargs
+ ret=callback(self.caller,**kwargs)
+ elifnargs>1:
+ # callback accepting raw_string
+ ret=callback(self.caller,raw_string)
+ else:
+ # normal callback, only the caller as arg
+ ret=callback(self.caller)
+ exceptEvMenuError:
+ errmsg=_ERR_GENERAL.format(nodename=callback)
+ self.msg(errmsg)
+ logger.log_trace()
+ raise
+
+ returnret
+
+ def_execute_node(self,nodename,raw_string,**kwargs):
+ """
+ Execute a node.
+
+ Args:
+ nodename (str): Name of node.
+ raw_string (str): The raw default string entered on the
+ previous node (only used if the node accepts it as an
+ argument)
+ kwargs (any, optional): Optional kwargs for the node.
+
+ Returns:
+ nodetext, options (tuple): The node text (a string or a
+ tuple and the options tuple, if any.
+
+ """
+ try:
+ node=self._menutree[nodename]
+ exceptKeyError:
+ self.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename))
+ raiseEvMenuError
+ try:
+ kwargs["_current_nodename"]=nodename
+ ret=self._safe_call(node,raw_string,**kwargs)
+ ifisinstance(ret,(tuple,list))andlen(ret)>1:
+ nodetext,options=ret[:2]
+ else:
+ nodetext,options=ret,None
+ exceptKeyError:
+ self.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename))
+ logger.log_trace()
+ raiseEvMenuError
+ exceptException:
+ self.msg(_ERR_GENERAL.format(nodename=nodename))
+ logger.log_trace()
+ raise
+
+ # store options to make them easier to test
+ self.test_options=options
+ self.test_nodetext=nodetext
+
+ returnnodetext,options
+
+
[docs]defmsg(self,txt):
+ """
+ This is a central point for sending return texts to the caller. It
+ allows for a central point to add custom messaging when creating custom
+ EvMenu overrides.
+
+ Args:
+ txt (str): The text to send.
+
+ Notes:
+ By default this will send to the same session provided to EvMenu
+ (if `session` kwarg was provided to `EvMenu.__init__`). It will
+ also send it with a `type=menu` for the benefit of OOB/webclient.
+
+ """
+ self.caller.msg(text=(txt,{"type":"menu"}),session=self._session)
+
+
[docs]defrun_exec(self,nodename,raw_string,**kwargs):
+ """
+ NOTE: This is deprecated. Use `goto` directly instead.
+
+ Run a function or node as a callback (with the 'exec' option key).
+
+ Args:
+ nodename (callable or str): A callable to run as
+ `callable(caller, raw_string)`, or the Name of an existing
+ node to run as a callable. This may or may not return
+ a string.
+ raw_string (str): The raw default string entered on the
+ previous node (only used if the node accepts it as an
+ argument)
+ kwargs (any): These are optional kwargs passed into goto
+
+ Returns:
+ new_goto (str or None): A replacement goto location string or
+ None (no replacement).
+ Notes:
+ Relying on exec callbacks to set the goto location is
+ very powerful but will easily lead to spaghetti structure and
+ hard-to-trace paths through the menu logic. So be careful with
+ relying on this.
+
+ """
+ try:
+ ifcallable(nodename):
+ # this is a direct callable - execute it directly
+ ret=self._safe_call(nodename,raw_string,**kwargs)
+ ifisinstance(ret,(tuple,list)):
+ ifnotlen(ret)>1ornotisinstance(ret[1],dict):
+ raiseEvMenuError(
+ "exec callable must return either None, str or (str, dict)"
+ )
+ ret,kwargs=ret[:2]
+ else:
+ # nodename is a string; lookup as node and run as node in-place (don't goto it)
+ # execute the node
+ ret=self._execute_node(nodename,raw_string,**kwargs)
+ ifisinstance(ret,(tuple,list)):
+ ifnotlen(ret)>1andret[1]andnotisinstance(ret[1],dict):
+ raiseEvMenuError("exec node must return either None, str or (str, dict)")
+ ret,kwargs=ret[:2]
+ exceptEvMenuErroraserr:
+ errmsg="Error in exec '%s' (input: '%s'): %s"%(nodename,raw_string.rstrip(),err)
+ self.msg("|r%s|n"%errmsg)
+ logger.log_trace(errmsg)
+ return
+
+ ifisinstance(ret,str):
+ # only return a value if a string (a goto target), ignore all other returns
+ ifnotret:
+ # an empty string - rerun the same node
+ returnself.nodename
+ returnret,kwargs
+ returnNone
+
+
[docs]defextract_goto_exec(self,nodename,option_dict):
+ """
+ Helper: Get callables and their eventual kwargs.
+
+ Args:
+ nodename (str): The current node name (used for error reporting).
+ option_dict (dict): The seleted option's dict.
+
+ Returns:
+ goto (str, callable or None): The goto directive in the option.
+ goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty.
+ execute (callable or None): Executable given by the `exec` directive.
+ exec_kwargs (dict): Kwargs for `execute` if it's callable, otherwise empty.
+
+ """
+ goto_kwargs,exec_kwargs={},{}
+ goto,execute=option_dict.get("goto",None),option_dict.get("exec",None)
+ ifgotoandisinstance(goto,(tuple,list)):
+ iflen(goto)>1:
+ goto,goto_kwargs=goto[:2]# ignore any extra arguments
+ ifnothasattr(goto_kwargs,"__getitem__"):
+ # not a dict-like structure
+ raiseEvMenuError(
+ "EvMenu node {}: goto kwargs is not a dict: {}".format(
+ nodename,goto_kwargs
+ )
+ )
+ else:
+ goto=goto[0]
+ ifexecuteandisinstance(execute,(tuple,list)):
+ iflen(execute)>1:
+ execute,exec_kwargs=execute[:2]# ignore any extra arguments
+ ifnothasattr(exec_kwargs,"__getitem__"):
+ # not a dict-like structure
+ raiseEvMenuError(
+ "EvMenu node {}: exec kwargs is not a dict: {}".format(
+ nodename,goto_kwargs
+ )
+ )
+ else:
+ execute=execute[0]
+ returngoto,goto_kwargs,execute,exec_kwargs
+
+
[docs]defgoto(self,nodename,raw_string,**kwargs):
+ """
+ Run a node by name, optionally dynamically generating that name first.
+
+ Args:
+ nodename (str or callable): Name of node or a callable
+ to be called as `function(caller, raw_string, **kwargs)` or
+ `function(caller, **kwargs)` to return the actual goto string or
+ a ("nodename", kwargs) tuple.
+ raw_string (str): The raw default string entered on the
+ previous node (only used if the node accepts it as an
+ argument)
+ Keyword Args:
+ any: Extra arguments to goto callables.
+
+ """
+
+ ifcallable(nodename):
+ # run the "goto" callable, if possible
+ inp_nodename=nodename
+ nodename=self._safe_call(nodename,raw_string,**kwargs)
+ ifisinstance(nodename,(tuple,list)):
+ ifnotlen(nodename)>1ornotisinstance(nodename[1],dict):
+ raiseEvMenuError(
+ "{}: goto callable must return str or (str, dict)".format(inp_nodename)
+ )
+ nodename,kwargs=nodename[:2]
+ ifnotnodename:
+ # no nodename return. Re-run current node
+ nodename=self.nodename
+ try:
+ # execute the found node, make use of the returns.
+ nodetext,options=self._execute_node(nodename,raw_string,**kwargs)
+ exceptEvMenuError:
+ return
+
+ ifself._persistent:
+ self.caller.attributes.add(
+ "_menutree_saved_startnode",(nodename,(raw_string,kwargs))
+ )
+
+ # validation of the node return values
+ helptext=""
+ ifis_iter(nodetext):
+ iflen(nodetext)>1:
+ nodetext,helptext=nodetext[:2]
+ else:
+ nodetext=nodetext[0]
+ nodetext=""ifnodetextisNoneelsestr(nodetext)
+ options=[options]ifisinstance(options,dict)elseoptions
+
+ # this will be displayed in the given order
+ display_options=[]
+ # this is used for lookup
+ self.options={}
+ self.default=None
+ ifoptions:
+ forinum,dicinenumerate(options):
+ # fix up the option dicts
+ keys=make_iter(dic.get("key"))
+ desc=dic.get("desc",dic.get("text",None))
+ if"_default"inkeys:
+ keys=[keyforkeyinkeysifkey!="_default"]
+ goto,goto_kwargs,execute,exec_kwargs=self.extract_goto_exec(nodename,dic)
+ self.default=(goto,goto_kwargs,execute,exec_kwargs)
+ else:
+ # use the key (only) if set, otherwise use the running number
+ keys=list(make_iter(dic.get("key",str(inum+1).strip())))
+ goto,goto_kwargs,execute,exec_kwargs=self.extract_goto_exec(nodename,dic)
+ ifkeys:
+ display_options.append((keys[0],desc))
+ forkeyinkeys:
+ ifgotoorexecute:
+ self.options[strip_ansi(key).strip().lower()]=(
+ goto,
+ goto_kwargs,
+ execute,
+ exec_kwargs,
+ )
+
+ self.nodetext=self._format_node(nodetext,display_options)
+ self.node_kwargs=kwargs
+ self.nodename=nodename
+
+ # handle the helptext
+ ifhelptext:
+ self.helptext=self.helptext_formatter(helptext)
+ elifoptions:
+ self.helptext=_HELP_FULLifself.auto_quitelse_HELP_NO_QUIT
+ else:
+ self.helptext=_HELP_NO_OPTIONSifself.auto_quitelse_HELP_NO_OPTIONS_NO_QUIT
+
+ self.display_nodetext()
+ ifnotoptions:
+ self.close_menu()
+
+
[docs]defrun_exec_then_goto(self,runexec,goto,raw_string,runexec_kwargs=None,goto_kwargs=None):
+ """
+ Call 'exec' callback and goto (which may also be a callable) in sequence.
+
+ Args:
+ runexec (callable or str): Callback to run before goto. If
+ the callback returns a string, this is used to replace
+ the `goto` string/callable before being passed into the goto handler.
+ goto (str): The target node to go to next (may be replaced
+ by `runexec`)..
+ raw_string (str): The original user input.
+ runexec_kwargs (dict, optional): Optional kwargs for runexec.
+ goto_kwargs (dict, optional): Optional kwargs for goto.
+
+ """
+ ifrunexec:
+ # replace goto only if callback returns
+ goto,goto_kwargs=self.run_exec(
+ runexec,raw_string,**(runexec_kwargsifrunexec_kwargselse{})
+ )or(goto,goto_kwargs)
+ ifgoto:
+ self.goto(goto,raw_string,**(goto_kwargsifgoto_kwargselse{}))
+
+
[docs]defclose_menu(self):
+ """
+ Shutdown menu; occurs when reaching the end node or using the quit command.
+ """
+ ifnotself._quitting:
+ # avoid multiple calls from different sources
+ self._quitting=True
+ self.caller.cmdset.remove(EvMenuCmdSet)
+ delself.caller.ndb._evmenu
+ ifself._persistent:
+ self.caller.attributes.remove("_menutree_saved")
+ self.caller.attributes.remove("_menutree_saved_startnode")
+ ifself.cmd_on_exitisnotNone:
+ self.cmd_on_exit(self.caller,self)
+ # special for template-generated menues
+ delself.caller.db._evmenu_template_contents
+
+
[docs]defprint_debug_info(self,arg):
+ """
+ Messages the caller with the current menu state, for debug purposes.
+
+ Args:
+ arg (str): Arg to debug instruction, either nothing, 'full' or the name
+ of a property to inspect.
+
+ """
+ all_props=inspect.getmembers(self)
+ all_methods=[nameforname,_ininspect.getmembers(self,predicate=inspect.ismethod)]
+ all_builtins=[nameforname,_ininspect.getmembers(self,predicate=inspect.isbuiltin)]
+ props={
+ prop:value
+ forprop,valueinall_props
+ ifpropnotinall_methodsandpropnotinall_builtinsandnotprop.endswith("__")
+ }
+
+ local={
+ key:var
+ forkey,varinlocals().items()
+ ifkeynotinall_propsandnotkey.endswith("__")
+ }
+
+ ifarg:
+ ifarginprops:
+ debugtxt=" |y* {}:|n\n{}".format(arg,props[arg])
+ elifarginlocal:
+ debugtxt=" |y* {}:|n\n{}".format(arg,local[arg])
+ elifarg=="full":
+ debugtxt=(
+ "|yMENU DEBUG full ... |n\n"
+ +"\n".join(
+ "|y *|n {}: {}".format(key,val)forkey,valinsorted(props.items())
+ )
+ +"\n |yLOCAL VARS:|n\n"
+ +"\n".join(
+ "|y *|n {}: {}".format(key,val)forkey,valinsorted(local.items())
+ )
+ +"\n |y... END MENU DEBUG|n"
+ )
+ else:
+ debugtxt="|yUsage: menudebug full|<name of property>|n"
+ else:
+ debugtxt=(
+ "|yMENU DEBUG properties ... |n\n"
+ +"\n".join(
+ "|y *|n {}: {}".format(key,crop(to_str(val,force_string=True),width=50))
+ forkey,valinsorted(props.items())
+ )
+ +"\n |yLOCAL VARS:|n\n"
+ +"\n".join(
+ "|y *|n {}: {}".format(key,crop(to_str(val,force_string=True),width=50))
+ forkey,valinsorted(local.items())
+ )
+ +"\n |y... END MENU DEBUG|n"
+ )
+ self.msg(debugtxt)
+
+
[docs]defparse_input(self,raw_string):
+ """
+ Parses the incoming string from the menu user.
+
+ Args:
+ raw_string (str): The incoming, unmodified string
+ from the user.
+ Notes:
+ This method is expected to parse input and use the result
+ to relay execution to the relevant methods of the menu. It
+ should also report errors directly to the user.
+
+ """
+ cmd=strip_ansi(raw_string.strip().lower())
+
+ try:
+ ifself.optionsandcmdinself.options:
+ # this will take precedence over the default commands
+ # below
+ goto,goto_kwargs,execfunc,exec_kwargs=self.options[cmd]
+ self.run_exec_then_goto(execfunc,goto,raw_string,exec_kwargs,goto_kwargs)
+ elifself.auto_lookandcmdin("look","l"):
+ self.display_nodetext()
+ elifself.auto_helpandcmdin("help","h"):
+ self.display_helptext()
+ elifself.auto_quitandcmdin("quit","q","exit"):
+ self.close_menu()
+ elifself.debug_modeandcmd.startswith("menudebug"):
+ self.print_debug_info(cmd[9:].strip())
+ elifself.default:
+ goto,goto_kwargs,execfunc,exec_kwargs=self.default
+ self.run_exec_then_goto(execfunc,goto,raw_string,exec_kwargs,goto_kwargs)
+ else:
+ self.msg(_HELP_NO_OPTION_MATCH)
+ exceptEvMenuGotoAbortMessageaserr:
+ # custom interrupt from inside a goto callable - print the message and
+ # stay on the current node.
+ self.msg(str(err))
[docs]defnodetext_formatter(self,nodetext):
+ """
+ Format the node text itself.
+
+ Args:
+ nodetext (str): The full node text (the text describing the node).
+
+ Returns:
+ nodetext (str): The formatted node text.
+
+ """
+ returndedent(nodetext.strip("\n"),baseline_index=0).rstrip()
+
+
[docs]defhelptext_formatter(self,helptext):
+ """
+ Format the node's help text
+
+ Args:
+ helptext (str): The unformatted help text for the node.
+
+ Returns:
+ helptext (str): The formatted help text.
+
+ """
+ returndedent(helptext.strip("\n"),baseline_index=0).rstrip()
+
+
[docs]defoptions_formatter(self,optionlist):
+ """
+ Formats the option block.
+
+ Args:
+ optionlist (list): List of (key, description) tuples for every
+ option related to this node.
+ caller (Object, Account or None, optional): The caller of the node.
+
+ Returns:
+ options (str): The formatted option display.
+
+ """
+ ifnotoptionlist:
+ return""
+
+ # column separation distance
+ colsep=4
+
+ nlist=len(optionlist)
+
+ # get the widest option line in the table.
+ table_width_max=-1
+ table=[]
+ forkey,descinoptionlist:
+ ifkeyordesc:
+ desc_string=": %s"%descifdescelse""
+ table_width_max=max(
+ table_width_max,
+ max(m_len(p)forpinkey.split("\n"))
+ +max(m_len(p)forpindesc_string.split("\n"))
+ +colsep,
+ )
+ raw_key=strip_ansi(key)
+ ifraw_key!=key:
+ # already decorations in key definition
+ table.append(" |lc%s|lt%s|le%s"%(raw_key,key,desc_string))
+ else:
+ # add a default white color to key
+ table.append(" |lc%s|lt|w%s|n|le%s"%(raw_key,raw_key,desc_string))
+ ncols=_MAX_TEXT_WIDTH//table_width_max# number of ncols
+
+ ifncols<0:
+ # no visible option at all
+ return""
+
+ ncols=ncols+1ifncols==0elsencols
+ # get the amount of rows needed (start with 4 rows)
+ nrows=4
+ whilenrows*ncols<nlist:
+ nrows+=1
+ ncols=nlist//nrows# number of full columns
+ nlastcol=nlist%nrows# number of elements in last column
+
+ # get the final column count
+ ncols=ncols+1ifnlastcol>0elsencols
+ ifncols>1:
+ # only extend if longer than one column
+ table.extend([" "foriinrange(nrows-nlastcol)])
+
+ # build the actual table grid
+ table=[table[icol*nrows:(icol*nrows)+nrows]foricolinrange(0,ncols)]
+
+ # adjust the width of each column
+ foricolinrange(len(table)):
+ col_width=(
+ max(max(m_len(p)forpinpart.split("\n"))forpartintable[icol])+colsep
+ )
+ table[icol]=[pad(part,width=col_width+colsep,align="l")forpartintable[icol]]
+
+ # format the table into columns
+ returnstr(EvTable(table=table,border="none"))
+
+
[docs]defnode_formatter(self,nodetext,optionstext):
+ """
+ Formats the entirety of the node.
+
+ Args:
+ nodetext (str): The node text as returned by `self.nodetext_formatter`.
+ optionstext (str): The options display as returned by `self.options_formatter`.
+ caller (Object, Account or None, optional): The caller of the node.
+
+ Returns:
+ node (str): The formatted node to display.
+
+ """
+ sep=self.node_border_char
+
+ ifself._session:
+ screen_width=self._session.protocol_flags.get("SCREENWIDTH",{0:_MAX_TEXT_WIDTH})[0]
+ else:
+ screen_width=_MAX_TEXT_WIDTH
+
+ nodetext_width_max=max(m_len(line)forlineinnodetext.split("\n"))
+ options_width_max=max(m_len(line)forlineinoptionstext.split("\n"))
+ total_width=min(screen_width,max(options_width_max,nodetext_width_max))
+ separator1=sep*total_width+"\n\n"ifnodetext_width_maxelse""
+ separator2="\n"+sep*total_width+"\n\n"iftotal_widthelse""
+ returnseparator1+"|n"+nodetext+"|n"+separator2+"|n"+optionstext
+
+
+# -----------------------------------------------------------
+#
+# List node (decorator turning a node into a list with
+# look/edit/add functionality for the elements)
+#
+# -----------------------------------------------------------
+
+
+
[docs]deflist_node(option_generator,select=None,pagesize=10):
+ """
+ Decorator for making an EvMenu node into a multi-page list node. Will add new options,
+ prepending those options added in the node.
+
+ Args:
+ option_generator (callable or list): A list of strings indicating the options, or a callable
+ that is called as option_generator(caller) to produce such a list.
+ select (callable or str, optional): Node to redirect a selection to. Its `**kwargs` will
+ contain the `available_choices` list and `selection` will hold one
+ of the elements in that list. If a callable, it will be called as
+ `select(caller, menuchoice, **kwargs)` where menuchoice is the
+ chosen option as a string and `available_choices` is the list of available
+ options offered by the option_generator. The callable whould return
+ the name of the target node to goto after this selection (or None to repeat the
+ list-node). Note that if this is not given, the decorated node
+ must itself provide a way to continue from the node!
+ pagesize (int): How many options to show per page.
+
+ Example:
+ ::
+
+ def _selectfunc(caller, menuchoice, **kwargs):
+ # menuchoice would be either 'foo' or 'bar' here
+ # kwargs['available_choices'] would be the list ['foo', 'bar']
+ return "the_next_node_to_go_to"
+
+ @list_node(['foo', 'bar'], _selectfunc)
+ def node_index(caller):
+ text = "describing the list"
+ return text, []
+
+ Notes:
+ All normal `goto` or `exec` callables returned from the decorated nodes will, if they accept
+ `**kwargs`, get a new kwarg `available_choices` injected. This is the ordered list of named
+ options (descs) visible on the current node page.
+
+ """
+
+ defdecorator(func):
+ def_select_parser(caller,raw_string,**kwargs):
+ """
+ Parse the select action
+ """
+ available_choices=kwargs.get("available_choices",[])
+
+ try:
+ index=int(raw_string.strip())-1
+ selection=available_choices[index]
+ exceptException:
+ caller.msg("|rInvalid choice.|n")
+ else:
+ ifcallable(select):
+ try:
+ ifbool(getargspec(select).keywords):
+ returnselect(caller,selection,available_choices=available_choices)
+ else:
+ returnselect(caller,selection)
+ exceptException:
+ logger.log_trace()
+ elifselect:
+ # we assume a string was given, we inject the result into the kwargs
+ # to pass on to the next node
+ kwargs["selection"]=selection
+ returnstr(select)
+ # this means the previous node will be re-run with these same kwargs
+ returnNone
+
+ def_list_node(caller,raw_string,**kwargs):
+
+ option_list=(
+ option_generator(caller)ifcallable(option_generator)elseoption_generator
+ )
+
+ npages=0
+ page_index=0
+ page=[]
+ options=[]
+
+ ifoption_list:
+ nall_options=len(option_list)
+ pages=[
+ option_list[ind:ind+pagesize]forindinrange(0,nall_options,pagesize)
+ ]
+ npages=len(pages)
+
+ page_index=max(0,min(npages-1,kwargs.get("optionpage_index",0)))
+ page=pages[page_index]
+
+ text=""
+ extra_text=None
+
+ # dynamic, multi-page option list. Each selection leads to the `select`
+ # callback being called with a result from the available choices
+ options.extend(
+ [
+ {"desc":opt,"goto":(_select_parser,{"available_choices":page})}
+ foroptinpage
+ ]
+ )
+
+ ifnpages>1:
+ # if the goto callable returns None, the same node is rerun, and
+ # kwargs not used by the callable are passed on to the node. This
+ # allows us to call ourselves over and over, using different kwargs.
+ options.append(
+ {
+ "key":("|Wcurrent|n","c"),
+ "desc":"|W({}/{})|n".format(page_index+1,npages),
+ "goto":(lambdacaller:None,{"optionpage_index":page_index}),
+ }
+ )
+ ifpage_index>0:
+ options.append(
+ {
+ "key":("|wp|Wrevious page|n","p"),
+ "goto":(lambdacaller:None,{"optionpage_index":page_index-1}),
+ }
+ )
+ ifpage_index<npages-1:
+ options.append(
+ {
+ "key":("|wn|Wext page|n","n"),
+ "goto":(lambdacaller:None,{"optionpage_index":page_index+1}),
+ }
+ )
+
+ # add data from the decorated node
+
+ decorated_options=[]
+ supports_kwargs=bool(getargspec(func).keywords)
+ try:
+ ifsupports_kwargs:
+ text,decorated_options=func(caller,raw_string,**kwargs)
+ else:
+ text,decorated_options=func(caller,raw_string)
+ exceptTypeError:
+ try:
+ ifsupports_kwargs:
+ text,decorated_options=func(caller,**kwargs)
+ else:
+ text,decorated_options=func(caller)
+ exceptException:
+ raise
+ exceptException:
+ logger.log_trace()
+ else:
+ ifisinstance(decorated_options,dict):
+ decorated_options=[decorated_options]
+ else:
+ decorated_options=make_iter(decorated_options)
+
+ extra_options=[]
+ ifisinstance(decorated_options,dict):
+ decorated_options=[decorated_options]
+ foreoptindecorated_options:
+ cback=("goto"ineoptand"goto")or("exec"ineoptand"exec")orNone
+ ifcback:
+ signature=eopt[cback]
+ ifcallable(signature):
+ # callable with no kwargs defined
+ eopt[cback]=(signature,{"available_choices":page})
+ elifis_iter(signature):
+ iflen(signature)>1andisinstance(signature[1],dict):
+ signature[1]["available_choices"]=page
+ eopt[cback]=signature
+ elifsignature:
+ # a callable alone in a tuple (i.e. no previous kwargs)
+ eopt[cback]=(signature[0],{"available_choices":page})
+ else:
+ # malformed input.
+ logger.log_err(
+ "EvMenu @list_node decorator found "
+ "malformed option to decorate: {}".format(eopt)
+ )
+ extra_options.append(eopt)
+
+ options.extend(extra_options)
+ text=text+"\n\n"+extra_textifextra_textelsetext
+
+ returntext,options
+
+ return_list_node
+
+ returndecorator
[docs]classCmdGetInput(Command):
+ """
+ Enter your data and press return.
+ """
+
+ key=_CMD_NOMATCH
+ aliases=_CMD_NOINPUT
+
+
[docs]deffunc(self):
+ """This is called when user enters anything."""
+ caller=self.caller
+ try:
+ getinput=caller.ndb._getinput
+ ifnotgetinputandhasattr(caller,"account"):
+ getinput=caller.account.ndb._getinput
+ caller=caller.account
+ callback=getinput._callback
+
+ caller.ndb._getinput._session=self.session
+ prompt=caller.ndb._getinput._prompt
+ args=caller.ndb._getinput._args
+ kwargs=caller.ndb._getinput._kwargs
+ result=self.raw_string.rstrip()# we strip the ending line break caused by sending
+
+ ok=notcallback(caller,prompt,result,*args,**kwargs)
+ ifok:
+ # only clear the state if the callback does not return
+ # anything
+ delcaller.ndb._getinput
+ caller.cmdset.remove(InputCmdSet)
+ exceptException:
+ # make sure to clean up cmdset if something goes wrong
+ caller.msg("|rError in get_input. Choice not confirmed (report to admin)|n")
+ logger.log_trace("Error in get_input")
+ caller.cmdset.remove(InputCmdSet)
[docs]defget_input(caller,prompt,callback,session=None,*args,**kwargs):
+ """
+ This is a helper function for easily request input from
+ the caller.
+
+ Args:
+ caller (Account or Object): The entity being asked
+ the question. This should usually be an object
+ controlled by a user.
+ prompt (str): This text will be shown to the user,
+ in order to let them know their input is needed.
+ callback (callable): A function that will be called
+ when the user enters a reply. It must take three
+ arguments: the `caller`, the `prompt` text and the
+ `result` of the input given by the user. If the
+ callback doesn't return anything or return False,
+ the input prompt will be cleaned up and exited. If
+ returning True, the prompt will remain and continue to
+ accept input.
+ session (Session, optional): This allows to specify the
+ session to send the prompt to. It's usually only
+ needed if `caller` is an Account in multisession modes
+ greater than 2. The session is then updated by the
+ command and is available (for example in callbacks)
+ through `caller.ndb.getinput._session`.
+ args, kwargs (optional): Extra arguments will be
+ passed to the fall back function as a list 'args'
+ and all keyword arguments as a dictionary 'kwargs'.
+ To utilise `*args` and `**kwargs`, a value for the
+ session argument must be provided (None by default)
+ and the callback function must take `*args` and
+ `**kwargs` as arguments.
+
+ Raises:
+ RuntimeError: If the given callback is not callable.
+
+ Notes:
+ The result value sent to the callback is raw and not
+ processed in any way. This means that you will get
+ the ending line return character from most types of
+ client inputs. So make sure to strip that before
+ doing a comparison.
+
+ When the prompt is running, a temporary object
+ `caller.ndb._getinput` is stored; this will be removed
+ when the prompt finishes.
+ If you need the specific Session of the caller (which
+ may not be easy to get if caller is an account in higher
+ multisession modes), then it is available in the
+ callback through `caller.ndb._getinput._session`.
+
+ Chaining get_input functions will result in the caller
+ stacking ever more instances of InputCmdSets. Whilst
+ they will all be cleared on concluding the get_input
+ chain, EvMenu should be considered for anything beyond
+ a single question.
+
+ """
+ ifnotcallable(callback):
+ raiseRuntimeError("get_input: input callback is not callable.")
+ caller.ndb._getinput=_Prompt()
+ caller.ndb._getinput._callback=callback
+ caller.ndb._getinput._prompt=prompt
+ caller.ndb._getinput._session=session
+ caller.ndb._getinput._args=args
+ caller.ndb._getinput._kwargs=kwargs
+ caller.cmdset.add(InputCmdSet)
+ caller.msg(prompt,session=session)
+
+
+# -------------------------------------------------------------
+#
+# Menu generation from menu template string
+#
+# -------------------------------------------------------------
+
+_RE_NODE=re.compile(r"##\s*?NODE\s+?(?P<nodename>\S[\S\s]*?)$",re.I+re.M)
+_RE_OPTIONS_SEP=re.compile(r"##\s*?OPTIONS\s*?$",re.I+re.M)
+_RE_CALLABLE=re.compile(r"\S+?\(\)",re.I+re.M)
+_RE_CALLABLE=re.compile(r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?)\)|\(\))",re.I+re.M)
+
+_HELP_NO_OPTION_MATCH=_("Choose an option or try 'help'.")
+
+_OPTION_INPUT_MARKER=">"
+_OPTION_ALIAS_MARKER=";"
+_OPTION_SEP_MARKER=":"
+_OPTION_CALL_MARKER="->"
+_OPTION_COMMENT_START="#"
+
+
+# Input/option/goto handler functions that allows for dynamically generated
+# nodes read from the menu template.
+
+
+def_process_callable(caller,goto,goto_callables,raw_string,current_nodename,kwargs):
+ """
+ Central helper for parsing a goto-callable (`funcname(**kwargs)`) out of
+ the right-hand-side of the template options and map this to an actual
+ callable registered with the template generator. This involves parsing the
+ func-name and running literal-eval on its kwargs.
+
+ """
+ match=_RE_CALLABLE.match(goto)
+ ifmatch:
+ gotofunc=match.group("funcname")
+ gotokwargs=match.group("kwargs")or""
+ ifgotofuncingoto_callables:
+ forkwargingotokwargs.split(","):
+ ifkwargand"="inkwarg:
+ key,value=[part.strip()forpartinkwarg.split("=",1)]
+ ifkeyin(
+ "evmenu_goto",
+ "evmenu_gotomap",
+ "_current_nodename",
+ "evmenu_current_nodename",
+ "evmenu_goto_callables",
+ ):
+ raiseRuntimeError(
+ f"EvMenu template error: goto-callable '{goto}' uses a "
+ f"kwarg ({kwarg}) that is reserved for the EvMenu templating "
+ "system. Rename the kwarg."
+ )
+ try:
+ key=literal_eval(key)
+ exceptValueError:
+ pass
+ try:
+ value=literal_eval(value)
+ exceptValueError:
+ pass
+ kwargs[key]=value
+
+ goto=goto_callables[gotofunc](caller,raw_string,**kwargs)
+ ifgotoisNone:
+ returngoto,{"generated_nodename":current_nodename}
+ returngoto,{"generated_nodename":goto}
+
+
+def_generated_goto_func(caller,raw_string,**kwargs):
+ """
+ This rerouter handles normal direct goto func call matches.
+
+ key : ... -> goto_callable(**kwargs)
+
+ """
+ goto=kwargs["evmenu_goto"]
+ goto_callables=kwargs["evmenu_goto_callables"]
+ current_nodename=kwargs["evmenu_current_nodename"]
+ return_process_callable(caller,goto,goto_callables,raw_string,current_nodename,kwargs)
+
+
+def_generated_input_goto_func(caller,raw_string,**kwargs):
+ """
+ This goto-func acts as a rerouter for >-type line parsing (by acting as the
+ _default option). The patterns discovered in the menu maps to different
+ *actual* goto-funcs. We map to those here.
+
+ >pattern: ... -> goto_callable
+
+ """
+ gotomap=kwargs["evmenu_gotomap"]
+ goto_callables=kwargs["evmenu_goto_callables"]
+ current_nodename=kwargs["evmenu_current_nodename"]
+ raw_string=raw_string.strip("\n")# strip is necessary to catch empty return
+
+ # start with glob patterns
+ forpattern,gotoingotomap.items():
+ iffnmatch(raw_string.lower(),pattern):
+ return_process_callable(
+ caller,goto,goto_callables,raw_string,current_nodename,kwargs
+ )
+ # no glob pattern match; try regex
+ forpattern,gotoingotomap.items():
+ ifpatternandre.match(pattern,raw_string.lower(),flags=re.I+re.M):
+ return_process_callable(
+ caller,goto,goto_callables,raw_string,current_nodename,kwargs
+ )
+ # no match, show error
+ raiseEvMenuGotoAbortMessage(_HELP_NO_OPTION_MATCH)
+
+
+def_generated_node(caller,raw_string,**kwargs):
+ """
+ Every node in the templated menu will be this node, but with dynamically
+ changing text/options. It must be a global function like this because
+ otherwise we could not make the templated-menu persistent.
+
+ """
+ text,options=caller.db._evmenu_template_contents[kwargs["_current_nodename"]]
+ returntext,options
+
+
+
[docs]defparse_menu_template(caller,menu_template,goto_callables=None):
+ """
+ Parse menu-template string. The main function of the EvMenu templating system.
+
+ Args:
+ caller (Object or Account): Entity using the menu.
+ menu_template (str): Menu described using the templating format.
+ goto_callables (dict, optional): Mapping between call-names and callables
+ on the form `callable(caller, raw_string, **kwargs)`. These are what is
+ available to use in the `menu_template` string.
+
+ Returns:
+ dict: A `{"node": nodefunc}` menutree suitable to pass into EvMenu.
+
+ """
+
+ def_validate_kwarg(goto,kwarg):
+ """
+ Validate goto-callable kwarg is on correct form.
+ """
+ ifnot"="inkwarg:
+ raiseRuntimeError(
+ f"EvMenu template error: goto-callable '{goto}' has a "
+ f"non-kwarg argument ({kwarg}). All callables in the "
+ "template must have only keyword-arguments, or no "
+ "args at all."
+ )
+ key,_=[part.strip()forpartinkwarg.split("=",1)]
+ ifkeyin(
+ "evmenu_goto",
+ "evmenu_gotomap",
+ "_current_nodename",
+ "evmenu_current_nodename",
+ "evmenu_goto_callables",
+ ):
+ raiseRuntimeError(
+ f"EvMenu template error: goto-callable '{goto}' uses a "
+ f"kwarg ({kwarg}) that is reserved for the EvMenu templating "
+ "system. Rename the kwarg."
+ )
+
+ def_parse_options(nodename,optiontxt,goto_callables):
+ """
+ Parse option section into option dict.
+ """
+ options=[]
+ optiontxt=optiontxt[0].strip()ifoptiontxtelse""
+ optionlist=[optline.strip()foroptlineinoptiontxt.split("\n")]
+ inputparsemap={}
+
+ forinum,optlineinenumerate(optionlist):
+ ifoptline.startswith(_OPTION_COMMENT_START)or_OPTION_SEP_MARKERnotinoptline:
+ # skip comments or invalid syntax
+ continue
+ key=""
+ desc=""
+ pattern=None
+
+ key,goto=[part.strip()forpartinoptline.split(_OPTION_SEP_MARKER,1)]
+
+ # desc -> goto
+ if_OPTION_CALL_MARKERingoto:
+ desc,goto=[part.strip()forpartingoto.split(_OPTION_CALL_MARKER,1)]
+
+ # validate callable
+ match=_RE_CALLABLE.match(goto)
+ ifmatch:
+ kwargs=match.group("kwargs")
+ ifkwargs:
+ forkwarginkwargs.split(","):
+ _validate_kwarg(goto,kwarg)
+
+ # parse key [;aliases|pattern]
+ key=[part.strip()forpartinkey.split(_OPTION_ALIAS_MARKER)]
+ ifnotkey:
+ # fall back to this being the Nth option
+ key=[f"{inum+1}"]
+ main_key=key[0]
+
+ ifmain_key.startswith(_OPTION_INPUT_MARKER):
+ # if we have a pattern, build the arguments for _default later
+ pattern=main_key[len(_OPTION_INPUT_MARKER):].strip()
+ inputparsemap[pattern]=goto
+ else:
+ # a regular goto string/callable target
+ option={
+ "key":key,
+ "goto":(
+ _generated_goto_func,
+ {
+ "evmenu_goto":goto,
+ "evmenu_current_nodename":nodename,
+ "evmenu_goto_callables":goto_callables,
+ },
+ ),
+ }
+ ifdesc:
+ option["desc"]=desc
+ options.append(option)
+
+ ifinputparsemap:
+ # if this exists we must create a _default entry too
+ options.append(
+ {
+ "key":"_default",
+ "goto":(
+ _generated_input_goto_func,
+ {
+ "evmenu_gotomap":inputparsemap,
+ "evmenu_current_nodename":nodename,
+ "evmenu_goto_callables":goto_callables,
+ },
+ ),
+ }
+ )
+
+ returnoptions
+
+ def_parse(caller,menu_template,goto_callables):
+ """
+ Parse the menu string format into a node tree.
+ """
+ nodetree={}
+ splits=_RE_NODE.split(menu_template)
+ splits=splits[1:]ifsplitselse[]
+
+ # from evennia import set_trace;set_trace(term_size=(140,120))
+ content_map={}
+ fornode_indinrange(0,len(splits),2):
+ nodename,nodetxt=splits[node_ind],splits[node_ind+1]
+ text,*optiontxt=_RE_OPTIONS_SEP.split(nodetxt,maxsplit=2)
+ options=_parse_options(nodename,optiontxt,goto_callables)
+ content_map[nodename]=(text,options)
+ nodetree[nodename]=_generated_node
+ caller.db._evmenu_template_contents=content_map
+
+ returnnodetree
+
+ return_parse(caller,menu_template,goto_callables)
+
+
+
[docs]deftemplate2menu(
+ caller,menu_template,goto_callables=None,startnode="start",persistent=False,**kwargs,
+):
+ """
+ Helper function to generate and start an EvMenu based on a menu template
+ string. This will internall call `parse_menu_template` and run a default
+ EvMenu with its results.
+
+ Args:
+ caller (Object or Account): The entity using the menu.
+ menu_template (str): The menu-template string describing the content
+ and structure of the menu. It can also be the python-path to, or a module
+ containing a `MENU_TEMPLATE` global variable with the template.
+ goto_callables (dict, optional): Mapping of callable-names to
+ module-global objects to reference by name in the menu-template.
+ Must be on the form `callable(caller, raw_string, **kwargs)`.
+ startnode (str, optional): The name of the startnode, if not 'start'.
+ persistent (bool, optional): If the generated menu should be persistent.
+ **kwargs: All kwargs will be passed into EvMenu.
+
+ Returns:
+ EvMenu: The generated EvMenu.
+
+ """
+ goto_callables=goto_callablesor{}
+ menu_tree=parse_menu_template(caller,menu_template,goto_callables)
+ returnEvMenu(caller,menu_tree,persistent=persistent,**kwargs,)
+# -*- coding: utf-8 -*-
+"""
+EvMore - pager mechanism
+
+This is a pager for displaying long texts and allows stepping up and
+down in the text (the name comes from the traditional 'more' unix
+command).
+
+To use, simply pass the text through the EvMore object:
+::
+
+ from evennia.utils.evmore import EvMore
+
+ text = some_long_text_output()
+ EvMore(caller, text, always_page=False, session=None, justify_kwargs=None, **kwargs)
+
+One can also use the convenience function msg from this module:
+::
+
+ from evennia.utils import evmore
+
+ text = some_long_text_output()
+ evmore.msg(caller, text, always_page=False, session=None, justify_kwargs=None, **kwargs)
+
+Where always_page decides if the pager is used also if the text is not long
+enough to need to scroll, session is used to determine which session to relay
+to and `justify_kwargs` are kwargs to pass to `utils.utils.justify` in order to
+change the formatting of the text. The remaining `**kwargs` will be passed on to
+the `caller.msg()` construct every time the page is updated.
+
+----
+
+"""
+fromdjango.confimportsettings
+fromdjango.db.models.queryimportQuerySet
+fromdjango.core.paginatorimportPaginator
+fromevenniaimportCommand,CmdSet
+fromevennia.commandsimportcmdhandler
+fromevennia.utils.utilsimportmake_iter,inherits_from,justify
+
+_CMD_NOMATCH=cmdhandler.CMD_NOMATCH
+_CMD_NOINPUT=cmdhandler.CMD_NOINPUT
+
+# we need to use NAWS for this
+_SCREEN_WIDTH=settings.CLIENT_DEFAULT_WIDTH
+_SCREEN_HEIGHT=settings.CLIENT_DEFAULT_HEIGHT
+
+_EVTABLE=None
+
+# text
+
+_DISPLAY="""{text}
+(|wmore|n [{pageno}/{pagemax}] retur|wn|n|||wb|nack|||wt|nop|||we|nnd|||wq|nuit)"""
+
+
+
[docs]classCmdMore(Command):
+ """
+ Manipulate the text paging
+ """
+
+ key=_CMD_NOINPUT
+ aliases=["quit","q","abort","a","next","n","back","b","top","t","end","e"]
+ auto_help=False
+
+
[docs]deffunc(self):
+ """
+ Implement the command
+ """
+ more=self.caller.ndb._more
+ ifnotmoreandhasattr(self.caller,"account"):
+ more=self.caller.account.ndb._more
+ ifnotmore:
+ self.caller.msg("Error in loading the pager. Contact an admin.")
+ return
+
+ cmd=self.cmdstring
+
+ ifcmdin("abort","a","q"):
+ more.page_quit()
+ elifcmdin("back","b"):
+ more.page_back()
+ elifcmdin("top","t","look","l"):
+ more.page_top()
+ elifcmdin("end","e"):
+ more.page_end()
+ else:
+ # return or n, next
+ more.page_next()
+
+
+
[docs]classCmdMoreLook(Command):
+ """
+ Override look to display window and prevent OOCLook from firing
+ """
+
+ key="look"
+ aliases=["l"]
+ auto_help=False
+
+
[docs]deffunc(self):
+ """
+ Implement the command
+ """
+ more=self.caller.ndb._more
+ ifnotmoreandhasattr(self.caller,"account"):
+ more=self.caller.account.ndb._more
+ ifnotmore:
+ self.caller.msg("Error in loading the pager. Contact an admin.")
+ return
+ more.display()
+
+
+
[docs]classCmdSetMore(CmdSet):
+ """
+ Stores the more command
+ """
+
+ key="more_commands"
+ priority=110
+
+
[docs]classEvMore:
+ """
+ The main pager object.
+
+ """
+
+
[docs]def__init__(
+ self,
+ caller,
+ inp,
+ always_page=False,
+ session=None,
+ justify=False,
+ justify_kwargs=None,
+ exit_on_lastpage=False,
+ exit_cmd=None,
+ page_formatter=str,
+ **kwargs,
+ ):
+
+ """
+ Initialization of the Evmore input handler.
+
+ Args:
+ caller (Object or Account): Entity reading the text.
+ inp (str, EvTable, Paginator or iterator): The text or data to put under paging.
+
+ - If a string, paginage normally. If this text contains
+ one or more \\\\f (backslash + f) format symbols, automatic
+ pagination and justification are force-disabled and
+ page-breaks will only happen after each \\\\f.
+ - If `EvTable`, the EvTable will be paginated with the same
+ setting on each page if it is too long. The table
+ decorations will be considered in the size of the page.
+ - Otherwise `inp` is converted to an iterator, where each step is
+ expected to be a line in the final display. Each line
+ will be run through `iter_callable`.
+
+ always_page (bool, optional): If `False`, the pager will only kick
+ in if `inp` is too big to fit the screen.
+ session (Session, optional): If given, this session will be used
+ to determine the screen width and will receive all output.
+ justify (bool, optional): If set, auto-justify long lines. This must be turned
+ off for fixed-width or formatted output, like tables. It's force-disabled
+ if `inp` is an EvTable.
+ justify_kwargs (dict, optional): Keywords for the justifiy function. Used only
+ if `justify` is True. If this is not set, default arguments will be used.
+ exit_on_lastpage (bool, optional): If reaching the last page without the
+ page being completely filled, exit pager immediately. If unset,
+ another move forward is required to exit. If set, the pager
+ exit message will not be shown.
+ exit_cmd (str, optional): If given, this command-string will be executed on
+ the caller when the more page exits. Note that this will be using whatever
+ cmdset the user had *before* the evmore pager was activated (so none of
+ the evmore commands will be available when this is run).
+ kwargs (any, any): These will be passed on to the `caller.msg` method.
+
+ Examples:
+ Basic use:
+ ::
+
+ super_long_text = " ... "
+ EvMore(caller, super_long_text)
+
+ Paginated query data - this is an optimization to avoid fetching
+ database data until it's actually paged to.
+ ::
+
+ from django.core.paginator import Paginator
+
+ query = ObjectDB.objects.all()
+ pages = Paginator(query, 10) # 10 objs per page
+ EvMore(caller, pages)
+
+ Automatic split EvTable over multiple EvMore pages
+ ::
+
+ table = EvMore(*header, table=tabledata)
+ EvMore(caller, table)
+
+ Every page a separate EvTable (optimization for very large data sets)
+ ::
+
+ from evennia import EvTable, EvMore
+
+ class TableEvMore(EvMore):
+ def init_pages(self, data):
+ pages = # depends on data type
+ super().init_pages(pages)
+
+ def page_formatter(self, page):
+ table = EvTable()
+
+ for line in page:
+ cols = # split raw line into columns
+ table.add_row(*cols)
+
+ return str(table)
+
+ TableEvMore(caller, pages)
+
+ """
+ self._caller=caller
+ self._always_page=always_page
+
+ ifnotsession:
+ # if not supplied, use the first session to
+ # determine screen size
+ sessions=caller.sessions.get()
+ ifnotsessions:
+ return
+ session=sessions[0]
+ self._session=session
+
+ self._justify=justify
+ self._justify_kwargs=justify_kwargs
+ self.exit_on_lastpage=exit_on_lastpage
+ self.exit_cmd=exit_cmd
+ self._exit_msg="Exited |wmore|n pager."
+ self._kwargs=kwargs
+
+ self._data=None
+
+ self._pages=[]
+ self._npos=0
+
+ self._npages=1
+ self._paginator=self.paginator_index
+ self._page_formatter=str
+
+ # set up individual pages for different sessions
+ height=max(4,session.protocol_flags.get("SCREENHEIGHT",{0:_SCREEN_HEIGHT})[0]-4)
+ self.width=session.protocol_flags.get("SCREENWIDTH",{0:_SCREEN_WIDTH})[0]
+ # always limit number of chars to 10 000 per page
+ self.height=min(10000//max(1,self.width),height)
+
+ # does initial parsing of input
+ self.init_pages(inp)
+
+ # kick things into gear
+ self.start()
+
+ # EvMore functional methods
+
+
[docs]defdisplay(self,show_footer=True):
+ """
+ Pretty-print the page.
+ """
+ pos=0
+ text="[no content]"
+ ifself._npages>0:
+ pos=self._npos
+ text=self.page_formatter(self.paginator(pos))
+ ifshow_footer:
+ page=_DISPLAY.format(text=text,pageno=pos+1,pagemax=self._npages)
+ else:
+ page=text
+ # check to make sure our session is still valid
+ sessions=self._caller.sessions.get()
+ ifnotsessions:
+ self.page_quit()
+ return
+ # this must be an 'is', not == check
+ ifnotany(sesforsesinsessionsifself._sessionisses):
+ self._session=sessions[0]
+ self._caller.msg(text=page,session=self._session,**self._kwargs)
+
+
[docs]defpage_top(self):
+ """
+ Display the top page
+ """
+ self._npos=0
+ self.display()
[docs]defpage_next(self):
+ """
+ Scroll the text to the next page. Quit if already at the end
+ of the page.
+ """
+ ifself._npos>=self._npages-1:
+ # exit if we are already at the end
+ self.page_quit()
+ else:
+ self._npos+=1
+ ifself.exit_on_lastpageandself._npos>=(self._npages-1):
+ self.display(show_footer=False)
+ self.page_quit(quiet=True)
+ else:
+ self.display()
+
+
[docs]defpage_back(self):
+ """
+ Scroll the text back up, at the most to the top.
+ """
+ self._npos=max(0,self._npos-1)
+ self.display()
[docs]defstart(self):
+ """
+ Starts the pagination
+ """
+ ifself._npages<=1andnotself._always_page:
+ # no need for paging; just pass-through.
+ self.display(show_footer=False)
+ else:
+ # go into paging mode
+ # first pass on the msg kwargs
+ self._caller.ndb._more=self
+ self._caller.cmdset.add(CmdSetMore)
+
+ # goto top of the text
+ self.page_top()
+
+ # default paginators - responsible for extracting a specific page number
+
+
[docs]defpaginator_index(self,pageno):
+ """Paginate to specific, known index"""
+ returnself._data[pageno]
+
+
[docs]defpaginator_slice(self,pageno):
+ """
+ Paginate by slice. This is done with an eye on memory efficiency (usually for
+ querysets); to avoid fetching all objects at the same time.
+ """
+ returnself._data[pageno*self.height:pageno*self.height+self.height]
+
+
[docs]defpaginator_django(self,pageno):
+ """
+ Paginate using the django queryset Paginator API. Note that his is indexed from 1.
+ """
+ returnself._data.page(pageno+1)
+
+ # default helpers to set up particular input types
+
+
[docs]definit_evtable(self,table):
+ """The input is an EvTable."""
+ iftable.height:
+ # enforced height of each paged table, plus space for evmore extras
+ self.height=table.height-4
+
+ # convert table to string
+ text=str(table)
+ self._justify=False
+ self._justify_kwargs=None# enforce
+ self.init_str(text)
+
+
[docs]definit_queryset(self,qs):
+ """The input is a queryset"""
+ nsize=qs.count()# we assume each will be a line
+ self._npages=nsize//self.height+(0ifnsize%self.height==0else1)
+ self._data=qs
+
+
[docs]definit_django_paginator(self,pages):
+ """
+ The input is a django Paginator object.
+ """
+ self._npages=pages.num_pages
+ self._data=pages
+
+
[docs]definit_iterable(self,inp):
+ """The input is something other than a string - convert to iterable of strings"""
+ inp=make_iter(inp)
+ nsize=len(inp)
+ self._npages=nsize//self.height+(0ifnsize%self.height==0else1)
+ self._data=inp
+
+
[docs]definit_f_str(self,text):
+ """
+ The input contains \\\\f (backslash + f) markers. We use \\\\f to indicate
+ the user wants to enforce their line breaks on their own. If so, we do
+ no automatic line-breaking/justification at all.
+
+ """
+ self._data=text.split("\f")
+ self._npages=len(self._data)
+
+
[docs]definit_str(self,text):
+ """The input is a string"""
+
+ ifself._justify:
+ # we must break very long lines into multiple ones. Note that this
+ # will also remove spurious whitespace.
+ justify_kwargs=self._justify_kwargsor{}
+ width=self._justify_kwargs.get("width",self.width)
+ justify_kwargs["width"]=width
+ justify_kwargs["align"]=self._justify_kwargs.get("align","l")
+ justify_kwargs["indent"]=self._justify_kwargs.get("indent",0)
+
+ lines=[]
+ forlineintext.split("\n"):
+ iflen(line)>width:
+ lines.extend(justify(line,**justify_kwargs).split("\n"))
+ else:
+ lines.append(line)
+ else:
+ # no justification. Simple division by line
+ lines=text.split("\n")
+
+ self._data=[
+ "\n".join(lines[i:i+self.height])foriinrange(0,len(lines),self.height)
+ ]
+ self._npages=len(self._data)
+
+ # Hooks for customizing input handling and formatting (override in a child class)
+
+
[docs]definit_pages(self,inp):
+ """
+ Initialize the pagination. By default, will analyze input type to determine
+ how pagination automatically.
+
+ Args:
+ inp (any): Incoming data to be paginated. By default, handles pagination of
+ strings, querysets, django.Paginator, EvTables and any iterables with strings.
+
+ Notes:
+ If overridden, this method must perform the following actions:
+
+ - read and re-store `self._data` (the incoming data set) if needed
+ for pagination to work.
+ - set `self._npages` to the total number of pages. Default is 1.
+ - set `self._paginator` to a callable that will take a page number 1...N and return
+ the data to display on that page (not any decorations or next/prev buttons). If only
+ wanting to change the paginator, override `self.paginator` instead.
+ - set `self._page_formatter` to a callable that will receive the
+ page from `self._paginator` and format it with one element per
+ line. Default is `str`. Or override `self.page_formatter`
+ directly instead.
+
+ By default, helper methods are called that perform these actions
+ depending on supported inputs.
+
+ """
+ ifinherits_from(inp,"evennia.utils.evtable.EvTable"):
+ # an EvTable
+ self.init_evtable(inp)
+ self._paginator=self.paginator_index
+ elifisinstance(inp,QuerySet):
+ # a queryset
+ self.init_queryset(inp)
+ self._paginator=self.paginator_slice
+ elifisinstance(inp,Paginator):
+ self.init_django_paginator(inp)
+ self._paginator=self.paginator_django
+ elifnotisinstance(inp,str):
+ # anything else not a str
+ self.init_iterable(inp)
+ self._paginator=self.paginator_slice
+ elif"\f"ininp:
+ # string with \f line-break markers in it
+ self.init_f_str(inp)
+ self._paginator=self.paginator_index
+ else:
+ # a string
+ self.init_str(inp)
+ self._paginator=self.paginator_index
+
+
[docs]defpaginator(self,pageno):
+ """
+ Paginator. The data operated upon is in `self._data`.
+
+ Args:
+ pageno (int): The page number to view, from 0...N-1
+ Returns:
+ str: The page to display (without any decorations, those are added
+ by EvMore).
+
+ """
+ returnself._paginator(pageno)
+
+
[docs]defpage_formatter(self,page):
+ """
+ Page formatter. Every page passes through this method. Override
+ it to customize behvaior per-page. A common use is to generate a new
+ EvTable for every page (this is more efficient than to generate one huge
+ EvTable across many pages and feed it into EvMore all at once).
+
+ Args:
+ page (any): A piece of data representing one page to display. This must
+
+ Returns:
+ str: A ready-formatted page to display. Extra footer with help about
+ switching to the next/prev page will be added automatically
+
+ """
+ returnself._page_formatter(page)
+
+
+# helper function
+
+
+
[docs]defmsg(
+ caller,
+ text="",
+ always_page=False,
+ session=None,
+ justify=False,
+ justify_kwargs=None,
+ exit_on_lastpage=True,
+ **kwargs,
+):
+ """
+ EvMore-supported version of msg, mimicking the normal msg method.
+
+ Args:
+ caller (Object or Account): Entity reading the text.
+ text (str, EvTable or iterator): The text or data to put under paging.
+
+ - If a string, paginage normally. If this text contains
+ one or more \\\\f (backslash + f) format symbol, automatic pagination is disabled
+ and page-breaks will only happen after each \\\\f.
+ - If `EvTable`, the EvTable will be paginated with the same
+ setting on each page if it is too long. The table
+ decorations will be considered in the size of the page.
+ - Otherwise `text` is converted to an iterator, where each step is
+ is expected to be a line in the final display, and each line
+ will be run through repr().
+
+ always_page (bool, optional): If `False`, the
+ pager will only kick in if `text` is too big
+ to fit the screen.
+ session (Session, optional): If given, this session will be used
+ to determine the screen width and will receive all output.
+ justify (bool, optional): If set, justify long lines in output. Disable for
+ fixed-format output, like tables.
+ justify_kwargs (dict, bool or None, optional): If given, this should
+ be valid keyword arguments to the utils.justify() function. If False,
+ no justification will be done.
+ exit_on_lastpage (bool, optional): Immediately exit pager when reaching the last page.
+ use_evtable (bool, optional): If True, each page will be rendered as an
+ EvTable. For this to work, `text` must be an iterable, where each element
+ is the table (list of list) to render on that page.
+ evtable_args (tuple, optional): The args to use for EvTable on each page.
+ evtable_kwargs (dict, optional): The kwargs to use for EvTable on each
+ page (except `table`, which is supplied by EvMore per-page).
+ kwargs (any, optional): These will be passed on
+ to the `caller.msg` method.
+
+ """
+ EvMore(
+ caller,
+ text,
+ always_page=always_page,
+ session=session,
+ justify=justify,
+ justify_kwargs=justify_kwargs,
+ exit_on_lastpage=exit_on_lastpage,
+ **kwargs,
+ )
+"""
+This is an advanced ASCII table creator. It was inspired by
+[prettytable](https://code.google.com/p/prettytable/) but shares no code.
+
+Example usage:
+::
+
+ from evennia.utils import evtable
+
+ table = evtable.EvTable("Heading1", "Heading2",
+ table=[[1,2,3],[4,5,6],[7,8,9]], border="cells")
+ table.add_column("This is long data", "This is even longer data")
+ table.add_row("This is a single row")
+ print table
+
+Result:
+::
+
+ +----------------------+----------+---+--------------------------+
+ | Heading1 | Heading2 | | |
+ +~~~~~~~~~~~~~~~~~~~~~~+~~~~~~~~~~+~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~+
+ | 1 | 4 | 7 | This is long data |
+ +----------------------+----------+---+--------------------------+
+ | 2 | 5 | 8 | This is even longer data |
+ +----------------------+----------+---+--------------------------+
+ | 3 | 6 | 9 | |
+ +----------------------+----------+---+--------------------------+
+ | This is a single row | | | |
+ +----------------------+----------+---+--------------------------+
+
+As seen, the table will automatically expand with empty cells to make
+the table symmetric. Tables can be restricted to a given width:
+::
+
+ table.reformat(width=50, align="l")
+
+(We could just have added these keywords to the table creation call)
+
+This yields the following result:
+::
+
+ +-----------+------------+-----------+-----------+
+ | Heading1 | Heading2 | | |
+ +~~~~~~~~~~~+~~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~~~~+
+ | 1 | 4 | 7 | This is |
+ | | | | long data |
+ +-----------+------------+-----------+-----------+
+ | | | | This is |
+ | 2 | 5 | 8 | even |
+ | | | | longer |
+ | | | | data |
+ +-----------+------------+-----------+-----------+
+ | 3 | 6 | 9 | |
+ +-----------+------------+-----------+-----------+
+ | This is a | | | |
+ | single | | | |
+ | row | | | |
+ +-----------+------------+-----------+-----------+
+
+Table-columns can be individually formatted. Note that if an
+individual column is set with a specific width, table auto-balancing
+will not affect this column (this may lead to the full table being too
+wide, so be careful mixing fixed-width columns with auto- balancing).
+Here we change the width and alignment of the column at index 3
+(Python starts from 0):
+::
+
+ table.reformat_column(3, width=30, align="r")
+ print table
+
+ +-----------+-------+-----+-----------------------------+---------+
+ | Heading1 | Headi | | | |
+ | | ng2 | | | |
+ +~~~~~~~~~~~+~~~~~~~+~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+~~~~~~~~~+
+ | 1 | 4 | 7 | This is long data | Test1 |
+ +-----------+-------+-----+-----------------------------+---------+
+ | 2 | 5 | 8 | This is even longer data | Test3 |
+ +-----------+-------+-----+-----------------------------+---------+
+ | 3 | 6 | 9 | | Test4 |
+ +-----------+-------+-----+-----------------------------+---------+
+ | This is a | | | | |
+ | single | | | | |
+ | row | | | | |
+ +-----------+-------+-----+-----------------------------+---------+
+
+When adding new rows/columns their data can have its own alignments
+(left/center/right, top/center/bottom).
+
+If the height is restricted, cells will be restricted from expanding
+vertically. This will lead to text contents being cropped. Each cell
+can only shrink to a minimum width and height of 1.
+
+`EvTable` is intended to be used with [ANSIString](evennia.utils.ansi#ansistring)
+for supporting ANSI-coloured string types.
+
+When a cell is auto-wrapped across multiple lines, ANSI-reset
+sequences will be put at the end of each wrapped line. This means that
+the colour of a wrapped cell will not "bleed", but it also means that
+eventual colour outside the table will not transfer "across" a table,
+you need to re-set the color to have it appear on both sides of the
+table string.
+
+----
+
+"""
+
+fromdjango.confimportsettings
+fromtextwrapimportTextWrapper
+fromcopyimportdeepcopy,copy
+fromevennia.utils.utilsimportis_iter,display_lenasd_len
+fromevennia.utils.ansiimportANSIString
+
+_DEFAULT_WIDTH=settings.CLIENT_DEFAULT_WIDTH
+
+
+def_to_ansi(obj):
+ """
+ convert to ANSIString.
+
+ Args:
+ obj (str): Convert incoming text to
+ be ANSI aware ANSIStrings.
+ """
+ ifis_iter(obj):
+ return[_to_ansi(o)foroinobj]
+ else:
+ returnANSIString(obj)
+
+
+_whitespace="\t\n\x0b\x0c\r "
+
+
+
[docs]classANSITextWrapper(TextWrapper):
+ """
+ This is a wrapper work class for handling strings with ANSI tags
+ in it. It overloads the standard library `TextWrapper` class and
+ is used internally in `EvTable` and has no public methods.
+
+ """
+
+ def_munge_whitespace(self,text):
+ """_munge_whitespace(text : string) -> string
+
+ Munge whitespace in text: expand tabs and convert all other
+ whitespace characters to spaces. Eg. " foo\tbar\n\nbaz"
+ becomes " foo bar baz".
+ """
+ returntext
+
+ # TODO: Ignore expand_tabs/replace_whitespace until ANSIString handles them.
+ # - don't remove this code. /Griatch
+ # if self.expand_tabs:
+ # text = text.expandtabs()
+ # if self.replace_whitespace:
+ # if isinstance(text, str):
+ # text = text.translate(self.whitespace_trans)
+ # return text
+
+ def_split(self,text):
+ """_split(text : string) -> [string]
+
+ Split the text to wrap into indivisible chunks. Chunks are
+ not quite the same as words; see _wrap_chunks() for full
+ details. As an example, the text
+ Look, goof-ball -- use the -b option!
+ breaks into the following chunks:
+ 'Look,', ' ', 'goof-', 'ball', ' ', '--', ' ',
+ 'use', ' ', 'the', ' ', '-b', ' ', 'option!'
+ if break_on_hyphens is True, or in:
+ 'Look,', ' ', 'goof-ball', ' ', '--', ' ',
+ 'use', ' ', 'the', ' ', '-b', ' ', option!'
+ otherwise.
+ """
+ # NOTE-PYTHON3: The following code only roughly approximates what this
+ # function used to do. Regex splitting on ANSIStrings is
+ # dropping ANSI codes, so we're using ANSIString.split
+ # for the time being.
+ #
+ # A less hackier solution would be appreciated.
+ chunks=_to_ansi(text).split()
+
+ chunks=[chunk+" "forchunkinchunksifchunk]# remove empty chunks
+
+ iflen(chunks)>1:
+ chunks[-1]=chunks[-1][0:-1]
+
+ returnchunks
+
+ def_wrap_chunks(self,chunks):
+ """_wrap_chunks(chunks : [string]) -> [string]
+
+ Wrap a sequence of text chunks and return a list of lines of
+ length 'self.width' or less. (If 'break_long_words' is false,
+ some lines may be longer than this.) Chunks correspond roughly
+ to words and the whitespace between them: each chunk is
+ indivisible (modulo 'break_long_words'), but a line break can
+ come between any two chunks. Chunks should not have internal
+ whitespace; ie. a chunk is either all whitespace or a "word".
+ Whitespace chunks will be removed from the beginning and end of
+ lines, but apart from that whitespace is preserved.
+ """
+ lines=[]
+ ifself.width<=0:
+ raiseValueError("invalid width %r (must be > 0)"%self.width)
+
+ # Arrange in reverse order so items can be efficiently popped
+ # from a stack of chucks.
+ chunks.reverse()
+
+ whilechunks:
+
+ # Start the list of chunks that will make up the current line.
+ # cur_len is just the length of all the chunks in cur_line.
+ cur_line=[]
+ cur_len=0
+
+ # Figure out which static string will prefix this line.
+ iflines:
+ indent=self.subsequent_indent
+ else:
+ indent=self.initial_indent
+
+ # Maximum width for this line.
+ width=self.width-d_len(indent)
+
+ # First chunk on line is whitespace -- drop it, unless this
+ # is the very beginning of the text (ie. no lines started yet).
+ ifself.drop_whitespaceandchunks[-1].strip()==""andlines:
+ delchunks[-1]
+
+ whilechunks:
+ l=d_len(chunks[-1])
+
+ # Can at least squeeze this chunk onto the current line.
+ ifcur_len+l<=width:
+ cur_line.append(chunks.pop())
+ cur_len+=l
+
+ # Nope, this line is full.
+ else:
+ break
+
+ # The current line is full, and the next chunk is too big to
+ # fit on *any* line (not just this one).
+ ifchunksandd_len(chunks[-1])>width:
+ self._handle_long_word(chunks,cur_line,cur_len,width)
+
+ # If the last chunk on this line is all whitespace, drop it.
+ ifself.drop_whitespaceandcur_lineandcur_line[-1].strip()=="":
+ delcur_line[-1]
+
+ # Convert current line back to a string and store it in list
+ # of all lines (return value).
+ ifcur_line:
+ l=""
+ forwincur_line:# ANSI fix
+ l+=w#
+ lines.append(indent+l)
+ returnlines
[docs]defwrap(text,width=_DEFAULT_WIDTH,**kwargs):
+ """
+ Wrap a single paragraph of text, returning a list of wrapped lines.
+
+ Reformat the single paragraph in 'text' so it fits in lines of no
+ more than 'width' columns, and return a list of wrapped lines. By
+ default, tabs in 'text' are expanded with string.expandtabs(), and
+ all other whitespace characters (including newline) are converted to
+
+ Args:
+ text (str): Text to wrap.
+ width (int, optional): Width to wrap `text` to.
+
+ Keyword Args:
+ See TextWrapper class for available keyword args to customize
+ wrapping behaviour.
+
+ """
+ w=ANSITextWrapper(width=width,**kwargs)
+ returnw.wrap(text)
+
+
+
[docs]deffill(text,width=_DEFAULT_WIDTH,**kwargs):
+ """Fill a single paragraph of text, returning a new string.
+
+ Reformat the single paragraph in 'text' to fit in lines of no more
+ than 'width' columns, and return a new string containing the entire
+ wrapped paragraph. As with wrap(), tabs are expanded and other
+ whitespace characters converted to space.
+
+ Args:
+ text (str): Text to fill.
+ width (int, optional): Width of fill area.
+
+ Keyword Args:
+ See TextWrapper class for available keyword args to customize
+ filling behaviour.
+
+ """
+ w=ANSITextWrapper(width=width,**kwargs)
+ returnw.fill(text)
+
+
+# EvCell class (see further down for the EvTable itself)
+
+
+
[docs]classEvCell:
+ """
+ Holds a single data cell for the table. A cell has a certain width
+ and height and contains one or more lines of data. It can shrink
+ and resize as needed.
+
+ """
+
+
[docs]def__init__(self,data,**kwargs):
+ """
+ Args:
+ data (str): The un-padded data of the entry.
+
+ Keyword Args:
+ width (int): Desired width of cell. It will pad
+ to this size.
+ height (int): Desired height of cell. it will pad
+ to this size.
+ pad_width (int): General padding width. This can be overruled
+ by individual settings below.
+ pad_left (int): Number of extra pad characters on the left.
+ pad_right (int): Number of extra pad characters on the right.
+ pad_top (int): Number of extra pad lines top (will pad with `vpad_char`).
+ pad_bottom (int): Number of extra pad lines bottom (will pad with `vpad_char`).
+ pad_char (str)- pad character to use for padding. This is overruled
+ by individual settings below (default `" "`).
+ hpad_char (str): Pad character to use both for extra horizontal
+ padding (default `" "`).
+ vpad_char (str): Pad character to use for extra vertical padding
+ and for vertical fill (default `" "`).
+ fill_char (str): Character used to filling (expanding cells to
+ desired size). This can be overruled by individual settings below.
+ hfill_char (str): Character used for horizontal fill (default `" "`).
+ vfill_char (str): Character used for vertical fill (default `" "`).
+ align (str): Should be one of "l", "r" or "c" for left-, right- or center
+ horizontal alignment respectively. Default is left-aligned.
+ valign (str): Should be one of "t", "b" or "c" for top-, bottom and center
+ vertical alignment respectively. Default is centered.
+ border_width (int): General border width. This is overruled
+ by individual settings below.
+ border_left (int): Left border width.
+ border_right (int): Right border width.
+ border_top (int): Top border width.
+ border_bottom (int): Bottom border width.
+ border_char (str): This will use a single border char for all borders.
+ overruled by individual settings below.
+ border_left_char (str): Char used for left border.
+ border_right_char (str): Char used for right border.
+ border_top_char (str): Char used for top border.
+ border_bottom_char (str): Char user for bottom border.
+ corner_char (str): Character used when two borders cross. (default is "").
+ This is overruled by individual settings below.
+ corner_top_left_char (str): Char used for "nw" corner.
+ corner_top_right_char (str): Char used for "ne" corner.
+ corner_bottom_left_char (str): Char used for "sw" corner.
+ corner_bottom_right_char (str): Char used for "se" corner.
+ crop_string (str): String to use when cropping sideways, default is `'[...]'`.
+ crop (bool): Crop contentof cell rather than expand vertically, default=`False`.
+ enforce_size (bool): If true, the width/height of the cell is
+ strictly enforced and extra text will be cropped rather than the
+ cell growing vertically.
+
+ Raises:
+ Exception: for impossible cell size requirements where the
+ border width or height cannot fit, or the content is too
+ small.
+
+ """
+
+ self.formatted=None
+ padwidth=kwargs.get("pad_width",None)
+ padwidth=int(padwidth)ifpadwidthisnotNoneelseNone
+ self.pad_left=int(kwargs.get("pad_left",padwidthifpadwidthisnotNoneelse1))
+ self.pad_right=int(kwargs.get("pad_right",padwidthifpadwidthisnotNoneelse1))
+ self.pad_top=int(kwargs.get("pad_top",padwidthifpadwidthisnotNoneelse0))
+ self.pad_bottom=int(kwargs.get("pad_bottom",padwidthifpadwidthisnotNoneelse0))
+
+ self.enforce_size=kwargs.get("enforce_size",False)
+
+ # avoid multi-char pad_chars messing up counting
+ pad_char=kwargs.get("pad_char"," ")
+ pad_char=pad_char[0]ifpad_charelse" "
+ hpad_char=kwargs.get("hpad_char",pad_char)
+ self.hpad_char=hpad_char[0]ifhpad_charelsepad_char
+ vpad_char=kwargs.get("vpad_char",pad_char)
+ self.vpad_char=vpad_char[0]ifvpad_charelsepad_char
+
+ fill_char=kwargs.get("fill_char"," ")
+ fill_char=fill_char[0]iffill_charelse" "
+ hfill_char=kwargs.get("hfill_char",fill_char)
+ self.hfill_char=hfill_char[0]ifhfill_charelse" "
+ vfill_char=kwargs.get("vfill_char",fill_char)
+ self.vfill_char=vfill_char[0]ifvfill_charelse" "
+
+ self.crop_string=kwargs.get("crop_string","[...]")
+
+ # borders and corners
+ borderwidth=kwargs.get("border_width",0)
+ self.border_left=kwargs.get("border_left",borderwidth)
+ self.border_right=kwargs.get("border_right",borderwidth)
+ self.border_top=kwargs.get("border_top",borderwidth)
+ self.border_bottom=kwargs.get("border_bottom",borderwidth)
+
+ borderchar=kwargs.get("border_char",None)
+ self.border_left_char=kwargs.get("border_left_char",bordercharifbordercharelse"|")
+ self.border_right_char=kwargs.get(
+ "border_right_char",bordercharifbordercharelseself.border_left_char
+ )
+ self.border_top_char=kwargs.get("border_top_char",bordercharifbordercharelse"-")
+ self.border_bottom_char=kwargs.get(
+ "border_bottom_char",bordercharifbordercharelseself.border_top_char
+ )
+
+ corner_char=kwargs.get("corner_char","+")
+ self.corner_top_left_char=kwargs.get("corner_top_left_char",corner_char)
+ self.corner_top_right_char=kwargs.get("corner_top_right_char",corner_char)
+ self.corner_bottom_left_char=kwargs.get("corner_bottom_left_char",corner_char)
+ self.corner_bottom_right_char=kwargs.get("corner_bottom_right_char",corner_char)
+
+ # alignments
+ self.align=kwargs.get("align","l")
+ self.valign=kwargs.get("valign","c")
+
+ self.data=self._split_lines(_to_ansi(data))
+ self.raw_width=max(d_len(line)forlineinself.data)
+ self.raw_height=len(self.data)
+
+ # this is extra trimming required for cels in the middle of a table only
+ self.trim_horizontal=0
+ self.trim_vertical=0
+
+ # width/height is given without left/right or top/bottom padding
+ if"width"inkwargs:
+ width=kwargs.pop("width")
+ self.width=(
+ width-self.pad_left-self.pad_right-self.border_left-self.border_right
+ )
+ ifself.width<=0<self.raw_width:
+ raiseException("Cell width too small - no space for data.")
+ else:
+ self.width=self.raw_width
+ if"height"inkwargs:
+ height=kwargs.pop("height")
+ self.height=(
+ height-self.pad_top-self.pad_bottom-self.border_top-self.border_bottom
+ )
+ ifself.height<=0<self.raw_height:
+ raiseException("Cell height too small - no space for data.")
+ else:
+ self.height=self.raw_height
+
+ # prepare data
+ # self.formatted = self._reformat()
+
+ def_crop(self,text,width):
+ """
+ Apply cropping of text.
+
+ Args:
+ text (str): The text to crop.
+ width (int): The width to crop `text` to.
+
+ """
+ ifd_len(text)>width:
+ crop_string=self.crop_string
+ returntext[:width-d_len(crop_string)]+crop_string
+ returntext
+
+ def_reformat(self):
+ """
+ Apply all EvCells' formatting operations.
+
+ """
+ data=self._border(self._pad(self._valign(self._align(self._fit_width(self.data)))))
+ returndata
+
+ def_split_lines(self,text):
+ """
+ Simply split by linebreaks
+
+ Args:
+ text (str): text to split.
+
+ Returns:
+ split (list): split text.
+ """
+ returntext.split("\n")
+
+ def_fit_width(self,data):
+ """
+ Split too-long lines to fit the desired width of the Cell.
+
+ Args:
+ data (str): Text to adjust to the cell's width.
+
+ Returns:
+ adjusted data (str): The adjusted text.
+
+ Notes:
+ This also updates `raw_width`.
+
+
+ """
+ width=self.width
+ adjusted_data=[]
+ forlineindata:
+ if0<width<d_len(line):
+ # replace_whitespace=False, expand_tabs=False is a
+ # fix for ANSIString not supporting expand_tabs/translate
+ adjusted_data.extend(
+ [
+ ANSIString(part+ANSIString("|n"))
+ forpartinwrap(line,width=width,drop_whitespace=False)
+ ]
+ )
+ else:
+ adjusted_data.append(line)
+ ifself.enforce_size:
+ # don't allow too high cells
+ excess=len(adjusted_data)-self.height
+ ifexcess>0:
+ # too many lines. Crop and mark last line with crop_string
+ crop_string=self.crop_string
+ adjusted_data=adjusted_data[:-excess]
+ crop_string_length=len(crop_string)
+ iflen(adjusted_data[-1])>crop_string_length:
+ adjusted_data[-1]=adjusted_data[-1][:-crop_string_length]+crop_string
+ else:
+ adjusted_data[-1]+=crop_string
+ elifexcess<0:
+ # too few lines. Fill to height.
+ adjusted_data.extend([""for_inrange(excess)])
+
+ returnadjusted_data
+
+ def_center(self,text,width,pad_char):
+ """
+ Horizontally center text on line of certain width, using padding.
+
+ Args:
+ text (str): The text to center.
+ width (int): How wide the area is (in characters) where `text`
+ should be centered.
+ pad_char (str): Which padding character to use.
+
+ Returns:
+ text (str): Centered text.
+
+ """
+ excess=width-d_len(text)
+ ifexcess<=0:
+ returntext
+ ifexcess%2:
+ # uneven padding
+ narrowside=(excess//2)*pad_char
+ widerside=narrowside+pad_char
+ ifwidth%2:
+ returnnarrowside+text+widerside
+ else:
+ returnwiderside+text+narrowside
+ else:
+ # even padding - same on both sides
+ side=(excess//2)*pad_char
+ returnside+text+side
+
+ def_align(self,data):
+ """
+ Align list of rows of cell. Whitespace characters will be stripped
+ if there is only one whitespace character - otherwise, it's assumed
+ the caller may be trying some manual formatting in the text.
+
+ Args:
+ data (str): Text to align.
+
+ Returns:
+ text (str): Aligned result.
+
+ """
+ align=self.align
+ hfill_char=self.hfill_char
+ width=self.width
+ ifalign=="l":
+ lines=[
+ (
+ line.lstrip(" ")+" "
+ ifline.startswith(" ")andnotline.startswith(" ")
+ elseline
+ )
+ +hfill_char*(width-d_len(line))
+ forlineindata
+ ]
+ returnlines
+ elifalign=="r":
+ return[
+ hfill_char*(width-d_len(line))
+ +(
+ " "+line.rstrip(" ")
+ ifline.endswith(" ")andnotline.endswith(" ")
+ elseline
+ )
+ forlineindata
+ ]
+ else:# center, 'c'
+ return[self._center(line,self.width,self.hfill_char)forlineindata]
+
+ def_valign(self,data):
+ """
+ Align cell vertically
+
+ Args:
+ data (str): Text to align.
+
+ Returns:
+ text (str): Vertically aligned text.
+
+ """
+ valign=self.valign
+ height=self.height
+ cheight=len(data)
+ excess=height-cheight
+ padline=self.vfill_char*self.width
+
+ ifexcess<=0:
+ returndata
+ # only care if we need to add new lines
+ ifvalign=="t":
+ returndata+[padlinefor_inrange(excess)]
+ elifvalign=="b":
+ return[padlinefor_inrange(excess)]+data
+ else:# center
+ narrowside=[padlinefor_inrange(excess//2)]
+ widerside=narrowside+[padline]
+ ifexcess%2:
+ # uneven padding
+ ifheight%2:
+ returnwiderside+data+narrowside
+ else:
+ returnnarrowside+data+widerside
+ else:
+ # even padding, same on both sides
+ returnnarrowside+data+narrowside
+
+ def_pad(self,data):
+ """
+ Pad data with extra characters on all sides.
+
+ Args:
+ data (str): Text to pad.
+
+ Returns:
+ text (str): Padded text.
+
+ """
+ left=self.hpad_char*self.pad_left
+ right=self.hpad_char*self.pad_right
+ vfill=(self.width+self.pad_left+self.pad_right)*self.vpad_char
+ top=[vfillfor_inrange(self.pad_top)]
+ bottom=[vfillfor_inrange(self.pad_bottom)]
+ returntop+[left+line+rightforlineindata]+bottom
+
+ def_border(self,data):
+ """
+ Add borders to the cell.
+
+ Args:
+ data (str): Text to surround with borders.
+
+ Return:
+ text (str): Text with borders.
+
+ """
+
+ left=self.border_left_char*self.border_left+ANSIString("|n")
+ right=ANSIString("|n")+self.border_right_char*self.border_right
+
+ cwidth=(
+ self.width
+ +self.pad_left
+ +self.pad_right
+ +max(0,self.border_left-1)
+ +max(0,self.border_right-1)
+ )
+
+ vfill=self.corner_top_left_charifleftelse""
+ vfill+=cwidth*self.border_top_char
+ vfill+=self.corner_top_right_charifrightelse""
+ top=[vfillfor_inrange(self.border_top)]
+
+ vfill=self.corner_bottom_left_charifleftelse""
+ vfill+=cwidth*self.border_bottom_char
+ vfill+=self.corner_bottom_right_charifrightelse""
+ bottom=[vfillfor_inrange(self.border_bottom)]
+
+ returntop+[left+line+rightforlineindata]+bottom
+
+
[docs]defget_min_height(self):
+ """
+ Get the minimum possible height of cell, including at least
+ one line for data.
+
+ Returns:
+ min_height (int): The mininum height of cell.
+
+ """
+ returnself.pad_top+self.pad_bottom+self.border_bottom+self.border_top+1
+
+
[docs]defget_min_width(self):
+ """
+ Get the minimum possible width of cell, including at least one
+ character-width for data.
+
+ Returns:
+ min_width (int): The minimum width of cell.
+
+ """
+ returnself.pad_left+self.pad_right+self.border_left+self.border_right+1
+
+
[docs]defget_height(self):
+ """
+ Get natural height of cell, including padding.
+
+ Returns:
+ natural_height (int): Height of cell.
+
+ """
+ returnlen(self.formatted)# if self.formatted else 0
+
+
[docs]defget_width(self):
+ """
+ Get natural width of cell, including padding.
+
+ Returns:
+ natural_width (int): Width of cell.
+
+ """
+ returnd_len(self.formatted[0])# if self.formatted else 0
+
+
[docs]defreplace_data(self,data,**kwargs):
+ """
+ Replace cell data. This causes a full reformat of the cell.
+
+ Args:
+ data (str): Cell data.
+
+ Notes:
+ The available keyword arguments are the same as for
+ `EvCell.__init__`.
+
+ """
+ self.data=self._split_lines(_to_ansi(data))
+ self.raw_width=max(d_len(line)forlineinself.data)
+ self.raw_height=len(self.data)
+ self.reformat(**kwargs)
+
+
[docs]defreformat(self,**kwargs):
+ """
+ Reformat the EvCell with new options
+
+ Keyword Args:
+ The available keyword arguments are the same as for `EvCell.__init__`.
+
+ Raises:
+ Exception: If the cells cannot shrink enough to accomodate
+ the options or the data given.
+
+ """
+ # keywords that require manipulation
+ padwidth=kwargs.get("pad_width",None)
+ padwidth=int(padwidth)ifpadwidthisnotNoneelseNone
+ self.pad_left=int(
+ kwargs.pop("pad_left",padwidthifpadwidthisnotNoneelseself.pad_left)
+ )
+ self.pad_right=int(
+ kwargs.pop("pad_right",padwidthifpadwidthisnotNoneelseself.pad_right)
+ )
+ self.pad_top=int(
+ kwargs.pop("pad_top",padwidthifpadwidthisnotNoneelseself.pad_top)
+ )
+ self.pad_bottom=int(
+ kwargs.pop("pad_bottom",padwidthifpadwidthisnotNoneelseself.pad_bottom)
+ )
+
+ self.enforce_size=kwargs.get("enforce_size",False)
+
+ pad_char=kwargs.pop("pad_char",None)
+ hpad_char=kwargs.pop("hpad_char",pad_char)
+ self.hpad_char=hpad_char[0]ifhpad_charelseself.hpad_char
+ vpad_char=kwargs.pop("vpad_char",pad_char)
+ self.vpad_char=vpad_char[0]ifvpad_charelseself.vpad_char
+
+ fillchar=kwargs.pop("fill_char",None)
+ hfill_char=kwargs.pop("hfill_char",fillchar)
+ self.hfill_char=hfill_char[0]ifhfill_charelseself.hfill_char
+ vfill_char=kwargs.pop("vfill_char",fillchar)
+ self.vfill_char=vfill_char[0]ifvfill_charelseself.vfill_char
+
+ borderwidth=kwargs.get("border_width",None)
+ self.border_left=kwargs.pop(
+ "border_left",borderwidthifborderwidthisnotNoneelseself.border_left
+ )
+ self.border_right=kwargs.pop(
+ "border_right",borderwidthifborderwidthisnotNoneelseself.border_right
+ )
+ self.border_top=kwargs.pop(
+ "border_top",borderwidthifborderwidthisnotNoneelseself.border_top
+ )
+ self.border_bottom=kwargs.pop(
+ "border_bottom",borderwidthifborderwidthisnotNoneelseself.border_bottom
+ )
+
+ borderchar=kwargs.get("border_char",None)
+ self.border_left_char=kwargs.pop(
+ "border_left_char",bordercharifbordercharelseself.border_left_char
+ )
+ self.border_right_char=kwargs.pop(
+ "border_right_char",bordercharifbordercharelseself.border_right_char
+ )
+ self.border_top_char=kwargs.pop(
+ "border_topchar",bordercharifbordercharelseself.border_top_char
+ )
+ self.border_bottom_char=kwargs.pop(
+ "border_bottom_char",bordercharifbordercharelseself.border_bottom_char
+ )
+
+ corner_char=kwargs.get("corner_char",None)
+ self.corner_top_left_char=kwargs.pop(
+ "corner_top_left",corner_charifcorner_charisnotNoneelseself.corner_top_left_char
+ )
+ self.corner_top_right_char=kwargs.pop(
+ "corner_top_right",
+ corner_charifcorner_charisnotNoneelseself.corner_top_right_char,
+ )
+ self.corner_bottom_left_char=kwargs.pop(
+ "corner_bottom_left",
+ corner_charifcorner_charisnotNoneelseself.corner_bottom_left_char,
+ )
+ self.corner_bottom_right_char=kwargs.pop(
+ "corner_bottom_right",
+ corner_charifcorner_charisnotNoneelseself.corner_bottom_right_char,
+ )
+
+ # this is used by the table to adjust size of cells with borders in the middle
+ # of the table
+ self.trim_horizontal=kwargs.pop("trim_horizontal",self.trim_horizontal)
+ self.trim_vertical=kwargs.pop("trim_vertical",self.trim_vertical)
+
+ # fill all other properties
+ forkey,valueinkwargs.items():
+ setattr(self,key,value)
+
+ # Handle sizes
+ if"width"inkwargs:
+ width=kwargs.pop("width")
+ self.width=(
+ width
+ -self.pad_left
+ -self.pad_right
+ -self.border_left
+ -self.border_right
+ +self.trim_horizontal
+ )
+ # if self.width <= 0 and self.raw_width > 0:
+ ifself.width<=0<self.raw_width:
+ raiseException("Cell width too small, no room for data.")
+ if"height"inkwargs:
+ height=kwargs.pop("height")
+ self.height=(
+ height
+ -self.pad_top
+ -self.pad_bottom
+ -self.border_top
+ -self.border_bottom
+ +self.trim_vertical
+ )
+ ifself.height<=0<self.raw_height:
+ raiseException("Cell height too small, no room for data.")
+
+ # reformat (to new sizes, padding, header and borders)
+ self.formatted=self._reformat()
+
+
[docs]defget(self):
+ """
+ Get data, padded and aligned in the form of a list of lines.
+
+ """
+ self.formatted=self._reformat()
+ returnself.formatted
[docs]classEvColumn(object):
+ """
+ This class holds a list of Cells to represent a column of a table.
+ It holds operations and settings that affect *all* cells in the
+ column.
+
+ Columns are not intended to be used stand-alone; they should be
+ incorporated into an EvTable (like EvCells)
+
+ """
+
+
[docs]def__init__(self,*args,**kwargs):
+ """
+ Args:
+ Text for each row in the column
+
+ Keyword Args:
+ All `EvCell.__init_` keywords are available, these
+ settings will be persistently applied to every Cell in the
+ column.
+
+ """
+ self.options=kwargs# column-specific options
+ self.column=[EvCell(data,**kwargs)fordatainargs]
+
+ def_balance(self,**kwargs):
+ """
+ Make sure to adjust the width of all cells so we form a
+ coherent and lined-up column. Will enforce column-specific
+ options to cells.
+
+ Keyword Args:
+ Extra keywords to modify the column setting. Same keywords
+ as in `EvCell.__init__`.
+
+ """
+ col=self.column
+ # fixed options for the column will override those requested in the call!
+ # this is particularly relevant to things like width/height, to avoid
+ # fixed-widths columns from being auto-balanced
+ kwargs.update(self.options)
+ # use fixed width or adjust to the largest cell
+ if"width"notinkwargs:
+ [
+ cell.reformat()forcellincol
+ ]# this is necessary to get initial widths of all cells
+ kwargs["width"]=max(cell.get_width()forcellincol)ifcolelse0
+ [cell.reformat(**kwargs)forcellincol]
+
+
[docs]defadd_rows(self,*args,**kwargs):
+ """
+ Add new cells to column. They will be inserted as
+ a series of rows. It will inherit the options
+ of the rest of the column's cells (use update to change
+ options).
+
+ Args:
+ Texts for the new cells
+ ypos (int, optional): Index position in table before which to insert the
+ new column. Uses Python indexing, so to insert at the top,
+ use `ypos=0`. If not given, data will be inserted at the end
+ of the column.
+
+ Keyword Args:
+ Available keywods as per `EvCell.__init__`.
+
+ """
+ # column-level options override those in kwargs
+ options={**kwargs,**self.options}
+
+ ypos=kwargs.get("ypos",None)
+ ifyposisNoneorypos>len(self.column):
+ # add to the end
+ self.column.extend([EvCell(data,**options)fordatainargs])
+ else:
+ # insert cells before given index
+ ypos=min(len(self.column)-1,max(0,int(ypos)))
+ new_cells=[EvCell(data,**options)fordatainargs]
+ self.column=self.column[:ypos]+new_cells+self.column[ypos:]
+ # self._balance(**kwargs)
+
+
[docs]defreformat(self,**kwargs):
+ """
+ Change the options for the column.
+
+ Keyword Args:
+ Keywords as per `EvCell.__init__`.
+
+ """
+ self._balance(**kwargs)
+
+
[docs]defreformat_cell(self,index,**kwargs):
+ """
+ reformat cell at given index, keeping column options if
+ necessary.
+
+ Args:
+ index (int): Index location of the cell in the column,
+ starting from 0 for the first row to Nrows-1.
+
+ Keyword Args:
+ Keywords as per `EvCell.__init__`.
+
+ """
+ # column-level options take precedence here
+ kwargs.update(self.options)
+ self.column[index].reformat(**kwargs)
[docs]classEvTable(object):
+ """
+ The table class holds a list of EvColumns, each consisting of EvCells so
+ that the result is a 2D matrix.
+ """
+
+
[docs]def__init__(self,*args,**kwargs):
+ """
+ Args:
+ Header texts for the table.
+
+ Keyword Args:
+ table (list of lists or list of `EvColumns`, optional):
+ This is used to build the table in a quick way. If not
+ given, the table will start out empty and `add_` methods
+ need to be used to add rows/columns.
+ header (bool, optional): `True`/`False` - turn off the
+ header texts (`*args`) being treated as a header (such as
+ not adding extra underlining)
+ pad_width (int, optional): How much empty space to pad your cells with
+ (default is 1)
+ border (str, optional)): The border style to use. This is one of
+ - `None` - No border drawing at all.
+ - "table" - only a border around the whole table.
+ - "tablecols" - table and column borders. (default)
+ - "header" - only border under header.
+ - "cols" - only vertical borders.
+ - "incols" - vertical borders, no outer edges.
+ - "rows" - only borders between rows.
+ - "cells" - border around all cells.
+ border_width (int, optional): Width of table borders, if border is active.
+ Note that widths wider than 1 may give artifacts in the corners. Default is 1.
+ corner_char (str, optional): Character to use in corners when border is active.
+ Default is `+`.
+ corner_top_left_char (str, optional): Character used for "nw" corner of table.
+ Defaults to `corner_char`.
+ corner_top_right_char (str, optional): Character used for "ne" corner of table.
+ Defaults to `corner_char`.
+ corner_bottom_left_char (str, optional): Character used for "sw" corner of table.
+ Defaults to `corner_char`.
+ corner_bottom_right_char (str, optional): Character used for "se" corner of table.
+ Defaults to `corner_char`.
+ pretty_corners (bool, optional): Use custom characters to
+ make the table corners look "rounded". Uses UTF-8
+ characters. Defaults to `False` for maximum compatibility with various displays
+ that may occationally have issues with UTF-8 characters.
+ header_line_char (str, optional): Character to use for underlining
+ the header row (default is '~'). Requires `border` to not be `None`.
+ width (int, optional): Fixed width of table. If not set,
+ width is set by the total width of each column. This will
+ resize individual columns in the vertical direction to fit.
+ height (int, optional): Fixed height of table. Defaults to being unset. Width is
+ still given precedence. If given, table cells will crop text rather
+ than expand vertically.
+ evenwidth (bool, optional): Used with the `width` keyword. Adjusts columns to have as even width as
+ possible. This often looks best also for mixed-length tables. Default is `False`.
+ maxwidth (int, optional): This will set a maximum width
+ of the table while allowing it to be smaller. Only if it grows wider than this
+ size will it be resized by expanding horizontally (or crop `height` is given).
+ This keyword has no meaning if `width` is set.
+
+ Raises:
+ Exception: If given erroneous input or width settings for the data.
+
+ Notes:
+ Beyond those table-specific keywords, the non-overlapping keywords
+ of `EvCell.__init__` are also available. These will be passed down
+ to every cell in the table.
+
+ """
+ # at this point table is a 2D grid - a list of columns
+ # x is the column position, y the row
+ table=kwargs.pop("table",[])
+
+ # header is a list of texts. We merge it to the table's top
+ header=[_to_ansi(head)forheadinargs]
+ self.header=header!=[]
+ ifself.header:
+ iftable:
+ excess=len(header)-len(table)
+ ifexcess>0:
+ # header bigger than table
+ table.extend([]for_inrange(excess))
+ elifexcess<0:
+ # too short header
+ header.extend(_to_ansi([""for_inrange(abs(excess))]))
+ forix,headinginenumerate(header):
+ table[ix].insert(0,heading)
+ else:
+ table=[[heading]forheadinginheader]
+ # even though we inserted the header, we can still turn off
+ # header border underling etc. We only allow this if a header
+ # was actually set
+ self.header=kwargs.pop("header",self.header)ifself.headerelseFalse
+ hchar=kwargs.pop("header_line_char","~")
+ self.header_line_char=hchar[0]ifhcharelse"~"
+
+ border=kwargs.pop("border","tablecols")
+ ifborderisNone:
+ border="none"
+ ifbordernotin(
+ "none",
+ "table",
+ "tablecols",
+ "header",
+ "incols",
+ "cols",
+ "rows",
+ "cells",
+ ):
+ raiseException("Unsupported border type: '%s'"%border)
+ self.border=border
+
+ # border settings are passed into Cell as well (so kwargs.get and not pop)
+ self.border_width=kwargs.get("border_width",1)
+ self.corner_char=kwargs.get("corner_char","+")
+ pcorners=kwargs.pop("pretty_corners",False)
+ self.corner_top_left_char=_to_ansi(
+ kwargs.pop("corner_top_left_char","."ifpcornerselseself.corner_char)
+ )
+ self.corner_top_right_char=_to_ansi(
+ kwargs.pop("corner_top_right_char","."ifpcornerselseself.corner_char)
+ )
+ self.corner_bottom_left_char=_to_ansi(
+ kwargs.pop("corner_bottom_left_char"," "ifpcornerselseself.corner_char)
+ )
+ self.corner_bottom_right_char=_to_ansi(
+ kwargs.pop("corner_bottom_right_char"," "ifpcornerselseself.corner_char)
+ )
+
+ self.width=kwargs.pop("width",None)
+ self.height=kwargs.pop("height",None)
+ self.evenwidth=kwargs.pop("evenwidth",False)
+ self.maxwidth=kwargs.pop("maxwidth",None)
+ ifself.maxwidthandself.widthandself.maxwidth<self.width:
+ raiseException("table maxwidth < table width!")
+ # size in cell cols/rows
+ self.ncols=len(table)
+ self.nrows=max(len(col)forcolintable)iftableelse0
+ # size in characters (gets set when _balance is called)
+ self.nwidth=0
+ self.nheight=0
+ # save options
+ self.options=kwargs
+
+ # use the temporary table to generate the table on the fly, as a list of EvColumns
+ self.table=[EvColumn(*col,**kwargs)forcolintable]
+
+ # this is the actual working table
+ self.worktable=None
+
+ # balance the table
+ # self._balance()
+
+ def_cellborders(self,ix,iy,nx,ny,**kwargs):
+ """
+ Adds borders to the table by adjusting the input kwarg to
+ instruct cells to build a border in the right positions.
+
+ Args:
+ ix (int): x index positions in table.
+ iy (int): y index positions in table.
+ nx (int): x size of table.
+ ny (int): y size of table.
+
+ Keyword Args:
+ Keywords as per `EvTable.__init__`.
+
+ Returns:
+ table (str): string with the correct borders.
+
+ Notes:
+ A copy of the kwarg is returned to the cell. This is method
+ is called by self._borders.
+
+ """
+
+ ret=kwargs.copy()
+
+ # handle the various border modes
+ border=self.border
+ header=self.header
+
+ bwidth=self.border_width
+ headchar=self.header_line_char
+
+ defcorners(ret):
+ """Handle corners of table"""
+ ifix==0andiy==0:
+ ret["corner_top_left_char"]=self.corner_top_left_char
+ ifix==nxandiy==0:
+ ret["corner_top_right_char"]=self.corner_top_right_char
+ ifix==0andiy==ny:
+ ret["corner_bottom_left_char"]=self.corner_bottom_left_char
+ ifix==nxandiy==ny:
+ ret["corner_bottom_right_char"]=self.corner_bottom_right_char
+ returnret
+
+ defleft_edge(ret):
+ """add vertical border along left table edge"""
+ ifix==0:
+ ret["border_left"]=bwidth
+ # ret["trim_horizontal"] = bwidth
+ returnret
+
+ deftop_edge(ret):
+ """add border along top table edge"""
+ ifiy==0:
+ ret["border_top"]=bwidth
+ # ret["trim_vertical"] = bwidth
+ returnret
+
+ defright_edge(ret):
+ """add vertical border along right table edge"""
+ ifix==nx:# and 0 < iy < ny:
+ ret["border_right"]=bwidth
+ # ret["trim_horizontal"] = 0
+ returnret
+
+ defbottom_edge(ret):
+ """add border along bottom table edge"""
+ ifiy==ny:
+ ret["border_bottom"]=bwidth
+ # ret["trim_vertical"] = bwidth
+ returnret
+
+ defcols(ret):
+ """Adding vertical borders inside the table"""
+ if0<=ix<nx:
+ ret["border_right"]=bwidth
+ returnret
+
+ defrows(ret):
+ """Adding horizontal borders inside the table"""
+ if0<=iy<ny:
+ ret["border_bottom"]=bwidth
+ returnret
+
+ defhead(ret):
+ """Add header underline"""
+ ifiy==0:
+ # put different bottom line for header
+ ret["border_bottom"]=bwidth
+ ret["border_bottom_char"]=headchar
+ returnret
+
+ # use the helper functions to define various
+ # table "styles"
+
+ ifborderin("table","tablecols","cells"):
+ ret=bottom_edge(right_edge(top_edge(left_edge(corners(ret)))))
+ ifborderin("cols","tablecols","cells"):
+ ret=cols(right_edge(left_edge(ret)))
+ ifborderin"incols":
+ ret=cols(ret)
+ ifborderin("rows","cells"):
+ ret=rows(bottom_edge(top_edge(ret)))
+ ifheaderandbordernotin("none",None):
+ ret=head(ret)
+
+ returnret
+
+ def_borders(self):
+ """
+ Add borders to table. This is called from self._balance.
+ """
+ nx,ny=self.ncols-1,self.nrows-1
+ options=self.options
+ forix,colinenumerate(self.worktable):
+ foriy,cellinenumerate(col):
+ col.reformat_cell(iy,**self._cellborders(ix,iy,nx,ny,**options))
+
+ def_balance(self):
+ """
+ Balance the table. This means to make sure
+ all cells on the same row have the same height,
+ that all columns have the same number of rows
+ and that the table fits within the given width.
+ """
+
+ # we make all modifications on a working copy of the
+ # actual table. This allows us to add columns/rows
+ # and re-balance over and over without issue.
+ self.worktable=deepcopy(self.table)
+ # self._borders()
+ # return
+ options=copy(self.options)
+
+ # balance number of rows to make a rectangular table
+ # column by column
+ ncols=len(self.worktable)
+ nrows=[len(col)forcolinself.worktable]
+ nrowmax=max(nrows)ifnrowselse0
+ foricol,nrowinenumerate(nrows):
+ self.worktable[icol].reformat(**options)
+ ifnrow<nrowmax:
+ # add more rows to too-short columns
+ empty_rows=[""for_inrange(nrowmax-nrow)]
+ self.worktable[icol].add_rows(*empty_rows)
+ self.ncols=ncols
+ self.nrows=nrowmax
+
+ # add borders - these add to the width/height, so we must do this before calculating width/height
+ self._borders()
+
+ # equalize widths within each column
+ cwidths=[max(cell.get_width()forcellincol)forcolinself.worktable]
+
+ ifself.widthorself.maxwidthandself.maxwidth<sum(cwidths):
+ # we set a table width. Horizontal cells will be evenly distributed and
+ # expand vertically as needed (unless self.height is set, see below)
+
+ # use fixed width, or set to maxwidth
+ width=self.widthifself.widthelseself.maxwidth
+
+ ifncols:
+ # get minimum possible cell widths for each row
+ cwidths_min=[max(cell.get_min_width()forcellincol)forcolinself.worktable]
+ cwmin=sum(cwidths_min)
+
+ # get which cols have separately set widths - these should be locked
+ # note that we need to remove cwidths_min for each lock to avoid counting
+ # it twice (in cwmin and in locked_cols)
+ locked_cols={
+ icol:col.options["width"]-cwidths_min[icol]
+ foricol,colinenumerate(self.worktable)
+ if"width"incol.options
+ }
+ locked_width=sum(locked_cols.values())
+
+ excess=width-cwmin-locked_width
+
+ iflen(locked_cols)>=ncolsandexcess:
+ # we can't adjust the width at all - all columns are locked
+ raiseException(
+ "Cannot balance table to width %s - "
+ "all columns have a set, fixed width summing to %s!"
+ %(self.width,sum(cwidths))
+ )
+
+ ifexcess<0:
+ # the locked cols makes it impossible
+ raiseException(
+ "Cannot shrink table width to %s. "
+ "Minimum size (and/or fixed-width columns) "
+ "sets minimum at %s."%(self.width,cwmin+locked_width)
+ )
+
+ ifself.evenwidth:
+ # make each column of equal width
+ # use cwidths as a work-array to track weights
+ cwidths=copy(cwidths_min)
+ correction=0
+ whilecorrection<excess:
+ # flood-fill the minimum table starting with the smallest columns
+ ci=cwidths.index(min(cwidths))
+ ifciinlocked_cols:
+ # locked column, make sure it's not picked again
+ cwidths[ci]+=9999
+ cwidths_min[ci]=locked_cols[ci]
+ else:
+ cwidths_min[ci]+=1
+ correction+=1
+ cwidths=cwidths_min
+ else:
+ # make each column expand more proportional to their data size
+ # we use cwidth as a work-array to track weights
+ correction=0
+ whilecorrection<excess:
+ # fill wider columns first
+ ci=cwidths.index(max(cwidths))
+ ifciinlocked_cols:
+ # locked column, make sure it's not picked again
+ cwidths[ci]-=9999
+ cwidths_min[ci]=locked_cols[ci]
+ else:
+ cwidths_min[ci]+=1
+ correction+=1
+ # give a just changed col less prio next run
+ cwidths[ci]-=3
+ cwidths=cwidths_min
+
+ # reformat worktable (for width align)
+ forix,colinenumerate(self.worktable):
+ try:
+ col.reformat(width=cwidths[ix],**options)
+ exceptException:
+ raise
+
+ # equalize heights for each row (we must do this here, since it may have changed to fit new widths)
+ cheights=[
+ max(cell.get_height()forcellin(col[iy]forcolinself.worktable))
+ foriyinrange(nrowmax)
+ ]
+
+ ifself.height:
+ # if we are fixing the table height, it means cells must crop text instead of resizing.
+ ifnrowmax:
+
+ # get minimum possible cell heights for each column
+ cheights_min=[
+ max(cell.get_min_height()forcellin(col[iy]forcolinself.worktable))
+ foriyinrange(nrowmax)
+ ]
+ chmin=sum(cheights_min)
+
+ # get which cols have separately set heights - these should be locked
+ # note that we need to remove cheights_min for each lock to avoid counting
+ # it twice (in chmin and in locked_cols)
+ locked_cols={
+ icol:col.options["height"]-cheights_min[icol]
+ foricol,colinenumerate(self.worktable)
+ if"height"incol.options
+ }
+ locked_height=sum(locked_cols.values())
+
+ excess=self.height-chmin-locked_height
+
+ ifchmin>self.height:
+ # we cannot shrink any more
+ raiseException(
+ "Cannot shrink table height to %s. Minimum "
+ "size (and/or fixed-height rows) sets minimum at %s."
+ %(self.height,chmin+locked_height)
+ )
+
+ # now we add all the extra height up to the desired table-height.
+ # We do this so that the tallest cells gets expanded first (and
+ # thus avoid getting cropped)
+
+ even=self.height%2==0
+ correction=0
+ whilecorrection<excess:
+ # expand the cells with the most rows first
+ if0<=correction<nrowmaxandnrowmax>1:
+ # avoid adding to header first round (looks bad on very small tables)
+ ci=cheights[1:].index(max(cheights[1:]))+1
+ else:
+ ci=cheights.index(max(cheights))
+ ifciinlocked_cols:
+ # locked row, make sure it's not picked again
+ cheights[ci]-=9999
+ cheights_min[ci]=locked_cols[ci]
+ else:
+ cheights_min[ci]+=1
+ # change balance
+ ifci==0andself.header:
+ # it doesn't look very good if header expands too fast
+ cheights[ci]-=2ifevenelse3
+ cheights[ci]-=2ifevenelse1
+ correction+=1
+ cheights=cheights_min
+
+ # we must tell cells to crop instead of expanding
+ options["enforce_size"]=True
+
+ # reformat table (for vertical align)
+ forix,colinenumerate(self.worktable):
+ foriy,cellinenumerate(col):
+ try:
+ col.reformat_cell(iy,height=cheights[iy],**options)
+ exceptExceptionase:
+ msg="ix=%s, iy=%s, height=%s: %s"%(ix,iy,cheights[iy],e.message)
+ raiseException("Error in vertical align:\n%s"%msg)
+
+ # calculate actual table width/height in characters
+ self.cwidth=sum(cwidths)
+ self.cheight=sum(cheights)
+
+ def_generate_lines(self):
+ """
+ Generates lines across all columns
+ (each cell may contain multiple lines)
+ This will also balance the table.
+ """
+ self._balance()
+ foriyinrange(self.nrows):
+ cell_row=[col[iy]forcolinself.worktable]
+ # this produces a list of lists, each of equal length
+ cell_data=[cell.get()forcellincell_row]
+ cell_height=min(len(lines)forlinesincell_data)
+ forilineinrange(cell_height):
+ yieldANSIString("").join(_to_ansi(celldata[iline]forcelldataincell_data))
+
+
[docs]defadd_header(self,*args,**kwargs):
+ """
+ Add header to table. This is a number of texts to be put at
+ the top of the table. They will replace an existing header.
+
+ Args:
+ args (str): These strings will be used as the header texts.
+
+ Keyword Args:
+ Same keywords as per `EvTable.__init__`. Will be applied
+ to the new header's cells.
+
+ """
+ self.header=True
+ self.add_row(ypos=0,*args,**kwargs)
+
+
[docs]defadd_column(self,*args,**kwargs):
+ """
+ Add a column to table. If there are more rows in new column
+ than there are rows in the current table, the table will
+ expand with empty rows in the other columns. If too few, the
+ new column with get new empty rows. All filling rows are added
+ to the end.
+
+ Args:
+ args (`EvColumn` or multiple strings): Either a single EvColumn instance or
+ a number of data string arguments to be used to create a new column.
+ header (str, optional): The header text for the column
+ xpos (int, optional): Index position in table *before* which
+ to input new column. If not given, column will be added to the end
+ of the table. Uses Python indexing (so first column is `xpos=0`)
+
+ Keyword Args:
+ Other keywords as per `Cell.__init__`.
+
+ """
+ # this will replace default options with new ones without changing default
+ options=dict(list(self.options.items())+list(kwargs.items()))
+
+ xpos=kwargs.get("xpos",None)
+ column=EvColumn(*args,**options)
+ wtable=self.ncols
+ htable=self.nrows
+
+ header=kwargs.get("header",None)
+ ifheader:
+ column.add_rows(str(header),ypos=0,**options)
+ self.header=True
+ elifself.header:
+ # we have a header already. Offset
+ column.add_rows("",ypos=0,**options)
+
+ # Calculate whether the new column needs to expand to the
+ # current table size, or if the table needs to expand to
+ # the column size.
+ # This needs to happen after the header rows have already been
+ # added to the column in order for the size calculations to match.
+ excess=len(column)-htable
+ ifexcess>0:
+ # we need to add new rows to table
+ forcolinself.table:
+ empty_rows=[""for_inrange(excess)]
+ col.add_rows(*empty_rows,**options)
+ self.nrows+=excess
+ elifexcess<0:
+ # we need to add new rows to new column
+ empty_rows=[""for_inrange(abs(excess))]
+ column.add_rows(*empty_rows,**options)
+ self.nrows-=excess
+
+ ifxposisNoneorxpos>wtable-1:
+ # add to the end
+ self.table.append(column)
+ else:
+ # insert column
+ xpos=min(wtable-1,max(0,int(xpos)))
+ self.table.insert(xpos,column)
+ self.ncols+=1
+ # self._balance()
+
+
[docs]defadd_row(self,*args,**kwargs):
+ """
+ Add a row to table (not a header). If there are more cells in
+ the given row than there are cells in the current table the
+ table will be expanded with empty columns to match. These will
+ be added to the end of the table. In the same way, adding a
+ line with too few cells will lead to the last ones getting
+ padded.
+
+ Args:
+ args (str): Any number of string argumnets to use as the
+ data in the row (one cell per argument).
+ ypos (int, optional): Index position in table before which to
+ input new row. If not given, will be added to the end of the table.
+ Uses Python indexing (so first row is `ypos=0`)
+
+ Keyword Args:
+ Other keywords are as per `EvCell.__init__`.
+
+ """
+ # this will replace default options with new ones without changing default
+ row=list(args)
+ options=dict(list(self.options.items())+list(kwargs.items()))
+
+ ypos=kwargs.get("ypos",None)
+ wtable=self.ncols
+ htable=self.nrows
+ excess=len(row)-wtable
+
+ ifexcess>0:
+ # we need to add new empty columns to table
+ empty_rows=[""for_inrange(htable)]
+ self.table.extend([EvColumn(*empty_rows,**options)for_inrange(excess)])
+ self.ncols+=excess
+ elifexcess<0:
+ # we need to add more cells to row
+ row.extend([""for_inrange(abs(excess))])
+ self.ncols-=excess
+
+ ifyposisNoneorypos>htable-1:
+ # add new row to the end
+ foricol,colinenumerate(self.table):
+ col.add_rows(row[icol],**options)
+ else:
+ # insert row elsewhere
+ ypos=min(htable-1,max(0,int(ypos)))
+ foricol,colinenumerate(self.table):
+ col.add_rows(row[icol],ypos=ypos,**options)
+ self.nrows+=1
+ # self._balance()
+
+
[docs]defreformat(self,**kwargs):
+ """
+ Force a re-shape of the entire table.
+
+ Keyword Args:
+ Table options as per `EvTable.__init__`.
+
+ """
+ self.width=kwargs.pop("width",self.width)
+ self.height=kwargs.pop("height",self.height)
+ forkey,valueinkwargs.items():
+ setattr(self,key,value)
+
+ hchar=kwargs.pop("header_line_char",self.header_line_char)
+
+ # border settings are also passed on into EvCells (so kwargs.get, not kwargs.pop)
+ self.header_line_char=hchar[0]ifhcharelseself.header_line_char
+ self.border_width=kwargs.get("border_width",self.border_width)
+ self.corner_char=kwargs.get("corner_char",self.corner_char)
+ self.header_line_char=kwargs.get("header_line_char",self.header_line_char)
+
+ self.corner_top_left_char=_to_ansi(kwargs.pop("corner_top_left_char",self.corner_char))
+ self.corner_top_right_char=_to_ansi(kwargs.pop("corner_top_right_char",self.corner_char))
+ self.corner_bottom_left_char=_to_ansi(
+ kwargs.pop("corner_bottom_left_char",self.corner_char)
+ )
+ self.corner_bottom_right_char=_to_ansi(
+ kwargs.pop("corner_bottom_right_char",self.corner_char)
+ )
+
+ self.options.update(kwargs)
+
+
[docs]defreformat_column(self,index,**kwargs):
+ """
+ Sends custom options to a specific column in the table.
+
+ Args:
+ index (int): Which column to reformat. The column index is
+ given from 0 to Ncolumns-1.
+
+ Keyword Args:
+ Column options as per `EvCell.__init__`.
+
+ Raises:
+ Exception: if an invalid index is found.
+
+ """
+ ifindex>len(self.table):
+ raiseException("Not a valid column index")
+ # we update the columns' options which means eventual width/height
+ # will be 'locked in' and withstand auto-balancing width/height from the table later
+ self.table[index].options.update(kwargs)
+ self.table[index].reformat(**kwargs)
+
+
[docs]defget(self):
+ """
+ Return lines of table as a list.
+
+ Returns:
+ table_lines (list): The lines of the table, in order.
+
+ """
+ return[lineforlineinself._generate_lines()]
+
+ def__str__(self):
+ """print table (this also balances it)"""
+ # h = "12345678901234567890123456789012345678901234567890123456789012345678901234567890"
+ returnstr(str(ANSIString("\n").join([lineforlineinself._generate_lines()])))
+
+
+def_test():
+ """Test"""
+ table=EvTable(
+ "|yHeading1|n",
+ "|gHeading2|n",
+ table=[[1,2,3],[4,5,6],[7,8,9]],
+ border="cells",
+ align="l",
+ )
+ table.add_column("|rThis is long data|n","|bThis is even longer data|n")
+ table.add_row("This is a single row")
+ print(str(table))
+ table.reformat(width=50)
+ print(str(table))
+ table.reformat_column(3,width=30,align="r")
+ print(str(table))
+ returntable
+
+
+def_test2():
+ table=EvTable("|yHeading1|n","|B|[GHeading2|n","Heading3")
+ foriinrange(100):
+ table.add_row(
+ "This is col 0, row %i"%i,
+ "|gThis is col 1, row |w%i|n|g.|n"%i,
+ "This is col 2, row %i"%i,
+ )
+ returntable
+
+"""
+The gametime module handles the global passage of time in the mud.
+
+It also supplies some useful methods to convert between
+in-mud time and real-world time as well allows to get the
+total runtime of the server and the current uptime.
+"""
+
+importtime
+fromcalendarimportmonthrange
+fromdatetimeimportdatetime,timedelta
+
+fromdjango.db.utilsimportOperationalError
+fromdjango.confimportsettings
+fromevenniaimportDefaultScript
+fromevennia.server.modelsimportServerConfig
+fromevennia.utils.createimportcreate_script
+
+# Speed-up factor of the in-game time compared
+# to real time.
+
+TIMEFACTOR=settings.TIME_FACTOR
+IGNORE_DOWNTIMES=settings.TIME_IGNORE_DOWNTIMES
+
+
+# Only set if gametime_reset was called at some point.
+try:
+ GAME_TIME_OFFSET=ServerConfig.objects.conf("gametime_offset",default=0)
+exceptOperationalError:
+ print("Gametime offset could not load - db not set up.")
+ GAME_TIME_OFFSET=0
+
+# Common real-life time measure, in seconds.
+# You should not change this.
+
+# these are kept updated by the server maintenance loop
+SERVER_START_TIME=0.0
+SERVER_RUNTIME_LAST_UPDATED=0.0
+SERVER_RUNTIME=0.0
+
+# note that these should not be accessed directly since they may
+# need further processing. Access from server_epoch() and game_epoch().
+_SERVER_EPOCH=None
+_GAME_EPOCH=None
+
+# Helper Script dealing in gametime (created by `schedule` function
+# below).
+
+
+
[docs]defat_repeat(self):
+ """Call the callback and reset interval."""
+ callback=self.db.callback
+ ifcallback:
+ callback()
+
+ seconds=real_seconds_until(**self.db.gametime)
+ self.restart(interval=seconds)
+
+
+# Access functions
+
+
+
[docs]defruntime():
+ """
+ Get the total runtime of the server since first start (minus
+ downtimes)
+
+ Args:
+ format (bool, optional): Format into a time representation.
+
+ Returns:
+ time (float or tuple): The runtime or the same time split up
+ into time units.
+
+ """
+ returnSERVER_RUNTIME+time.time()-SERVER_RUNTIME_LAST_UPDATED
+
+
+
[docs]defserver_epoch():
+ """
+ Get the server epoch. We may need to calculate this on the fly.
+
+ """
+ global_SERVER_EPOCH
+ ifnot_SERVER_EPOCH:
+ _SERVER_EPOCH=(
+ ServerConfig.objects.conf("server_epoch",default=None)ortime.time()-runtime()
+ )
+ return_SERVER_EPOCH
+
+
+
[docs]defuptime():
+ """
+ Get the current uptime of the server since last reload
+
+ Args:
+ format (bool, optional): Format into time representation.
+
+ Returns:
+ time (float or tuple): The uptime or the same time split up
+ into time units.
+
+ """
+ returntime.time()-SERVER_START_TIME
+
+
+
[docs]defportal_uptime():
+ """
+ Get the current uptime of the portal.
+
+ Returns:
+ time (float): The uptime of the portal.
+ """
+ fromevennia.server.sessionhandlerimportSESSIONS
+
+ returntime.time()-SESSIONS.portal_start_time
+
+
+
[docs]defgame_epoch():
+ """
+ Get the game epoch.
+
+ """
+ game_epoch=settings.TIME_GAME_EPOCH
+ returngame_epochifgame_epochisnotNoneelseserver_epoch()
+
+
+
[docs]defgametime(absolute=False):
+ """
+ Get the total gametime of the server since first start (minus downtimes)
+
+ Args:
+ absolute (bool, optional): Get the absolute game time, including
+ the epoch. This could be converted to an absolute in-game
+ date.
+
+ Returns:
+ time (float): The gametime as a virtual timestamp.
+
+ Notes:
+ If one is using a standard calendar, one could convert the unformatted
+ return to a date using Python's standard `datetime` module like this:
+ `datetime.datetime.fromtimestamp(gametime(absolute=True))`
+
+ """
+ epoch=game_epoch()ifabsoluteelse0
+ ifIGNORE_DOWNTIMES:
+ gtime=epoch+(time.time()-server_epoch())*TIMEFACTOR
+ else:
+ gtime=epoch+(runtime()-GAME_TIME_OFFSET)*TIMEFACTOR
+ returngtime
+
+
+
[docs]defreal_seconds_until(sec=None,min=None,hour=None,day=None,month=None,year=None):
+ """
+ Return the real seconds until game time.
+
+ Args:
+ sec (int or None): number of absolute seconds.
+ min (int or None): number of absolute minutes.
+ hour (int or None): number of absolute hours.
+ day (int or None): number of absolute days.
+ month (int or None): number of absolute months.
+ year (int or None): number of absolute years.
+
+ Returns:
+ The number of real seconds before the given game time is up.
+
+ Example:
+ real_seconds_until(hour=5, min=10, sec=0)
+
+ If the game time is 5:00, TIME_FACTOR is set to 2 and you ask
+ the number of seconds until it's 5:10, then this function should
+ return 300 (5 minutes).
+
+
+ """
+ current=datetime.fromtimestamp(gametime(absolute=True))
+ s_sec=secifsecisnotNoneelsecurrent.second
+ s_min=minifminisnotNoneelsecurrent.minute
+ s_hour=hourifhourisnotNoneelsecurrent.hour
+ s_day=dayifdayisnotNoneelsecurrent.day
+ s_month=monthifmonthisnotNoneelsecurrent.month
+ s_year=yearifyearisnotNoneelsecurrent.year
+ projected=datetime(s_year,s_month,s_day,s_hour,s_min,s_sec)
+
+ ifprojected<=current:
+ # We increase one unit of time depending on parameters
+ ifmonthisnotNone:
+ projected=projected.replace(year=s_year+1)
+ elifdayisnotNone:
+ try:
+ projected=projected.replace(month=s_month+1)
+ exceptValueError:
+ projected=projected.replace(month=1)
+ elifhourisnotNone:
+ projected+=timedelta(days=1)
+ elifminisnotNone:
+ projected+=timedelta(seconds=3600)
+ else:
+ projected+=timedelta(seconds=60)
+
+ # Get the number of gametime seconds between these two dates
+ seconds=(projected-current).total_seconds()
+ returnseconds/TIMEFACTOR
+
+
+
[docs]defschedule(
+ callback,repeat=False,sec=None,min=None,hour=None,day=None,month=None,year=None
+):
+ """
+ Call a callback at a given in-game time.
+
+ Args:
+ callback (function): The callback function that will be called. Note
+ that the callback must be a module-level function, since the script will
+ be persistent.
+ repeat (bool, optional): Defines if the callback should be called regularly
+ at the specified time.
+ sec (int or None): Number of absolute game seconds at which to run repeat.
+ min (int or None): Number of absolute minutes.
+ hour (int or None): Number of absolute hours.
+ day (int or None): Number of absolute days.
+ month (int or None): Number of absolute months.
+ year (int or None): Number of absolute years.
+
+ Returns:
+ script (Script): The created Script handling the sceduling.
+
+ Examples:
+ schedule(func, min=5, sec=0) # Will call 5 minutes past the next (in-game) hour.
+ schedule(func, hour=2, min=30, sec=0) # Will call the next (in-game) day at 02:30.
+ """
+ seconds=real_seconds_until(sec=sec,min=min,hour=hour,day=day,month=month,year=year)
+ script=create_script(
+ "evennia.utils.gametime.TimeScript",
+ key="TimeScript",
+ desc="A gametime-sensitive script",
+ interval=seconds,
+ start_delay=True,
+ repeats=-1ifrepeatelse1,
+ )
+ script.db.callback=callback
+ script.db.gametime={
+ "sec":sec,
+ "min":min,
+ "hour":hour,
+ "day":day,
+ "month":month,
+ "year":year,
+ }
+ returnscript
+
+
+
[docs]defreset_gametime():
+ """
+ Resets the game time to make it start from the current time. Note that
+ the epoch set by `settings.TIME_GAME_EPOCH` will still apply.
+
+ """
+ globalGAME_TIME_OFFSET
+ GAME_TIME_OFFSET=runtime()
+ ServerConfig.objects.conf("gametime_offset",GAME_TIME_OFFSET)
+"""
+IDmapper extension to the default manager.
+"""
+fromdjango.db.models.managerimportManager
+
+
+
[docs]classSharedMemoryManager(Manager):
+ # TODO: improve on this implementation
+ # We need a way to handle reverse lookups so that this model can
+ # still use the singleton cache, but the active model isn't required
+ # to be a SharedMemoryModel.
+
[docs]defget(self,*args,**kwargs):
+ """
+ Data entity lookup.
+ """
+ items=list(kwargs)
+ inst=None
+ iflen(items)==1:
+ # CL: support __exact
+ key=items[0]
+ ifkey.endswith("__exact"):
+ key=key[:-len("__exact")]
+ ifkeyin("pk",self.model._meta.pk.attname):
+ try:
+ inst=self.model.get_cached_instance(kwargs[items[0]])
+ # we got the item from cache, but if this is a fk, check it's ours
+ ifgetattr(inst,str(self.field).split(".")[-1])!=self.instance:
+ inst=None
+ exceptException:
+ pass
+ ifinstisNone:
+ inst=super().get(*args,**kwargs)
+ returninst
+"""
+Django ID mapper
+
+Modified for Evennia by making sure that no model references
+leave caching unexpectedly (no use of WeakRefs).
+
+Also adds `cache_size()` for monitoring the size of the cache.
+"""
+
+importos
+importthreading
+importgc
+importtime
+fromweakrefimportWeakValueDictionary
+fromtwisted.internet.reactorimportcallFromThread
+fromdjango.core.exceptionsimportObjectDoesNotExist,FieldError
+fromdjango.db.models.signalsimportpost_save
+fromdjango.db.models.baseimportModel,ModelBase
+fromdjango.db.models.signalsimportpre_delete,post_migrate
+fromdjango.db.utilsimportDatabaseError
+fromevennia.utilsimportlogger
+fromevennia.utils.utilsimportdbref,get_evennia_pids,to_str
+
+from.managerimportSharedMemoryManager
+
+AUTO_FLUSH_MIN_INTERVAL=60.0*5# at least 5 mins between cache flushes
+
+_GA=object.__getattribute__
+_SA=object.__setattr__
+_DA=object.__delattr__
+_MONITOR_HANDLER=None
+
+# References to db-updated objects are stored here so the
+# main process can be informed to re-cache itself.
+PROC_MODIFIED_COUNT=0
+PROC_MODIFIED_OBJS=WeakValueDictionary()
+
+# get info about the current process and thread; determine if our
+# current pid is different from the server PID (i.e. # if we are in a
+# subprocess or not)
+_SELF_PID=os.getpid()
+_SERVER_PID,_PORTAL_PID=get_evennia_pids()
+_IS_SUBPROCESS=(_SERVER_PIDand_PORTAL_PID)and_SELF_PIDnotin(_SERVER_PID,_PORTAL_PID)
+_IS_MAIN_THREAD=threading.currentThread().getName()=="MainThread"
+
+
+
[docs]classSharedMemoryModelBase(ModelBase):
+ # CL: upstream had a __new__ method that skipped ModelBase's __new__ if
+ # SharedMemoryModelBase was not in the model class's ancestors. It's not
+ # clear what was the intended purpose, but skipping ModelBase.__new__
+ # broke things; in particular, default manager inheritance.
+
+ def__call__(cls,*args,**kwargs):
+ """
+ this method will either create an instance (by calling the default implementation)
+ or try to retrieve one from the class-wide cache by inferring the pk value from
+ `args` and `kwargs`. If instance caching is enabled for this class, the cache is
+ populated whenever possible (ie when it is possible to infer the pk value).
+
+ """
+
+ defnew_instance():
+ returnsuper(SharedMemoryModelBase,cls).__call__(*args,**kwargs)
+
+ instance_key=cls._get_cache_key(args,kwargs)
+ # depending on the arguments, we might not be able to infer the PK, so in that case we create a new instance
+ ifinstance_keyisNone:
+ returnnew_instance()
+ cached_instance=cls.get_cached_instance(instance_key)
+ ifcached_instanceisNone:
+ cached_instance=new_instance()
+ cls.cache_instance(cached_instance,new=True)
+ returncached_instance
+
+ def_prepare(cls):
+ """
+ Prepare the cache, making sure that proxies of the same db base
+ share the same cache.
+
+ """
+ # the dbmodel is either the proxy base or ourselves
+ dbmodel=cls._meta.concrete_modelifcls._meta.proxyelsecls
+ cls.__dbclass__=dbmodel
+ ifnothasattr(dbmodel,"__instance_cache__"):
+ # we store __instance_cache__ only on the dbmodel base
+ dbmodel.__instance_cache__={}
+ super()._prepare()
+
+ def__new__(cls,name,bases,attrs):
+ """
+ Field shortcut creation:
+
+ Takes field names `db_*` and creates property wrappers named
+ without the `db_` prefix. So db_key -> key
+
+ This wrapper happens on the class level, so there is no
+ overhead when creating objects. If a class already has a
+ wrapper of the given name, the automatic creation is skipped.
+
+ Notes:
+ Remember to document this auto-wrapping in the class
+ header, this could seem very much like magic to the user
+ otherwise.
+ """
+
+ attrs["typename"]=cls.__name__
+ attrs["path"]="%s.%s"%(attrs["__module__"],name)
+ attrs["_is_deleted"]=False
+
+ # set up the typeclass handling only if a variable _is_typeclass is set on the class
+ defcreate_wrapper(cls,fieldname,wrappername,editable=True,foreignkey=False):
+ "Helper method to create property wrappers with unique names (must be in separate call)"
+
+ def_get(cls,fname):
+ "Wrapper for getting database field"
+ if_GA(cls,"_is_deleted"):
+ raiseObjectDoesNotExist(
+ "Cannot access %s: Hosting object was already deleted."%fname
+ )
+ return_GA(cls,fieldname)
+
+ def_get_foreign(cls,fname):
+ "Wrapper for returning foreignkey fields"
+ if_GA(cls,"_is_deleted"):
+ raiseObjectDoesNotExist(
+ "Cannot access %s: Hosting object was already deleted."%fname
+ )
+ return_GA(cls,fieldname)
+
+ def_set_nonedit(cls,fname,value):
+ "Wrapper for blocking editing of field"
+ raiseFieldError("Field %s cannot be edited."%fname)
+
+ def_set(cls,fname,value):
+ "Wrapper for setting database field"
+ if_GA(cls,"_is_deleted"):
+ raiseObjectDoesNotExist(
+ "Cannot set %s to %s: Hosting object was already deleted!"%(fname,value)
+ )
+ _SA(cls,fname,value)
+ # only use explicit update_fields in save if we actually have a
+ # primary key assigned already (won't be set when first creating object)
+ update_fields=(
+ [fname]if_GA(cls,"_get_pk_val")(_GA(cls,"_meta"))isnotNoneelseNone
+ )
+ _GA(cls,"save")(update_fields=update_fields)
+
+ def_set_foreign(cls,fname,value):
+ "Setter only used on foreign key relations, allows setting with #dbref"
+ if_GA(cls,"_is_deleted"):
+ raiseObjectDoesNotExist(
+ "Cannot set %s to %s: Hosting object was already deleted!"%(fname,value)
+ )
+ ifisinstance(value,(str,int)):
+ value=to_str(value)
+ ifvalue.isdigit()orvalue.startswith("#"):
+ # we also allow setting using dbrefs, if so we try to load the matching object.
+ # (we assume the object is of the same type as the class holding the field, if
+ # not a custom handler must be used for that field)
+ dbid=dbref(value,reqhash=False)
+ ifdbid:
+ model=_GA(cls,"_meta").get_field(fname).model
+ try:
+ value=model._default_manager.get(id=dbid)
+ exceptObjectDoesNotExist:
+ # maybe it is just a name that happens to look like a dbid
+ pass
+ _SA(cls,fname,value)
+ # only use explicit update_fields in save if we actually have a
+ # primary key assigned already (won't be set when first creating object)
+ update_fields=(
+ [fname]if_GA(cls,"_get_pk_val")(_GA(cls,"_meta"))isnotNoneelseNone
+ )
+ _GA(cls,"save")(update_fields=update_fields)
+
+ def_del_nonedit(cls,fname):
+ "wrapper for not allowing deletion"
+ raiseFieldError("Field %s cannot be edited."%fname)
+
+ def_del(cls,fname):
+ "Wrapper for clearing database field - sets it to None"
+ _SA(cls,fname,None)
+ update_fields=(
+ [fname]if_GA(cls,"_get_pk_val")(_GA(cls,"_meta"))isnotNoneelseNone
+ )
+ _GA(cls,"save")(update_fields=update_fields)
+
+ # wrapper factories
+ ifnoteditable:
+
+ deffget(cls):
+ return_get(cls,fieldname)
+
+ deffset(cls,val):
+ return_set_nonedit(cls,fieldname,val)
+
+ elifforeignkey:
+
+ deffget(cls):
+ return_get_foreign(cls,fieldname)
+
+ deffset(cls,val):
+ return_set_foreign(cls,fieldname,val)
+
+ else:
+
+ deffget(cls):
+ return_get(cls,fieldname)
+
+ deffset(cls,val):
+ return_set(cls,fieldname,val)
+
+ deffdel(cls):
+ return_del(cls,fieldname)ifeditableelse_del_nonedit(cls,fieldname)
+
+ # set docstrings for auto-doc
+ fget.__doc__="A wrapper for getting database field `%s`."%fieldname
+ fset.__doc__="A wrapper for setting (and saving) database field `%s`."%fieldname
+ fdel.__doc__="A wrapper for deleting database field `%s`."%fieldname
+ # assigning
+ attrs[wrappername]=property(fget,fset,fdel)
+ # type(cls).__setattr__(cls, wrappername, property(fget, fset, fdel))#, doc))
+
+ # exclude some models that should not auto-create wrapper fields
+ ifcls.__name__in("ServerConfig","TypeNick"):
+ return
+ # dynamically create the wrapper properties for all fields not already handled
+ # (manytomanyfields are always handlers)
+ forfieldname,fieldin(
+ (fname,field)
+ forfname,fieldinlist(attrs.items())
+ iffname.startswith("db_")andtype(field).__name__!="ManyToManyField"
+ ):
+ foreignkey=type(field).__name__=="ForeignKey"
+ wrappername="dbid"iffieldname=="id"elsefieldname.replace("db_","",1)
+ ifwrappernamenotinattrs:
+ # makes sure not to overload manually created wrappers on the model
+ create_wrapper(
+ cls,fieldname,wrappername,editable=field.editable,foreignkey=foreignkey
+ )
+
+ returnsuper().__new__(cls,name,bases,attrs)
+
+
+
[docs]classSharedMemoryModel(Model,metaclass=SharedMemoryModelBase):
+ """
+ Base class for idmapped objects. Inherit from `this`.
+ """
+
+ objects=SharedMemoryManager()
+
+
+
+ @classmethod
+ def_get_cache_key(cls,args,kwargs):
+ """
+ This method is used by the caching subsystem to infer the PK
+ value from the constructor arguments. It is used to decide if
+ an instance has to be built or is already in the cache.
+
+ """
+ result=None
+ # Quick hack for my composites work for now.
+ ifhasattr(cls._meta,"pks"):
+ pk=cls._meta.pks[0]
+ else:
+ pk=cls._meta.pk
+ # get the index of the pk in the class fields. this should be calculated *once*, but isn't atm
+ pk_position=cls._meta.fields.index(pk)
+ iflen(args)>pk_position:
+ # if it's in the args, we can get it easily by index
+ result=args[pk_position]
+ elifpk.attnameinkwargs:
+ # retrieve the pk value. Note that we use attname instead of name, to handle the case where the pk is a
+ # a ForeignKey.
+ result=kwargs[pk.attname]
+ elifpk.name!=pk.attnameandpk.nameinkwargs:
+ # ok we couldn't find the value, but maybe it's a FK and we can find the corresponding object instead
+ result=kwargs[pk.name]
+
+ ifresultisnotNoneandisinstance(result,Model):
+ # if the pk value happens to be a model instance (which can happen wich a FK), we'd rather use its own pk as the key
+ result=result._get_pk_val()
+ returnresult
+
+
[docs]@classmethod
+ defget_cached_instance(cls,id):
+ """
+ Method to retrieve a cached instance by pk value. Returns None
+ when not found (which will always be the case when caching is
+ disabled for this class). Please note that the lookup will be
+ done even when instance caching is disabled.
+
+ """
+ returncls.__dbclass__.__instance_cache__.get(id)
+
+
[docs]@classmethod
+ defcache_instance(cls,instance,new=False):
+ """
+ Method to store an instance in the cache.
+
+ Args:
+ instance (Class instance): the instance to cache.
+ new (bool, optional): this is the first time this instance is
+ cached (i.e. this is not an update operation like after a
+ db save).
+
+ """
+ pk=instance._get_pk_val()
+ ifpkisnotNone:
+ cls.__dbclass__.__instance_cache__[pk]=instance
+ ifnew:
+ try:
+ # trigger the at_init hook only
+ # at first initialization
+ instance.at_init()
+ exceptAttributeError:
+ # The at_init hook is not assigned to all entities
+ pass
+
+
[docs]@classmethod
+ defget_all_cached_instances(cls):
+ """
+ Return the objects so far cached by idmapper for this class.
+
+ """
+ returnlist(cls.__dbclass__.__instance_cache__.values())
+
+ @classmethod
+ def_flush_cached_by_key(cls,key,force=True):
+ """
+ Remove the cached reference.
+
+ """
+ try:
+ ifforceorcls.at_idmapper_flush():
+ delcls.__dbclass__.__instance_cache__[key]
+ else:
+ cls._dbclass__.__instance_cache__[key].refresh_from_db()
+ exceptKeyError:
+ # No need to remove if cache doesn't contain it already
+ pass
+
+
[docs]@classmethod
+ defflush_cached_instance(cls,instance,force=True):
+ """
+ Method to flush an instance from the cache. The instance will
+ always be flushed from the cache, since this is most likely
+ called from delete(), and we want to make sure we don't cache
+ dead objects.
+
+ """
+ cls._flush_cached_by_key(instance._get_pk_val(),force=force)
[docs]defat_idmapper_flush(self):
+ """
+ This is called when the idmapper cache is flushed and
+ allows customized actions when this happens.
+
+ Returns:
+ do_flush (bool): If True, flush this object as normal. If
+ False, don't flush and expect this object to handle
+ the flushing on its own.
+ """
+ returnTrue
+
+
[docs]defflush_from_cache(self,force=False):
+ """
+ Flush this instance from the instance cache. Use
+ `force` to override the result of at_idmapper_flush() for the object.
+
+ """
+ pk=self._get_pk_val()
+ ifpk:
+ ifforceorself.at_idmapper_flush():
+ self.__class__.__dbclass__.__instance_cache__.pop(pk,None)
[docs]defsave(self,*args,**kwargs):
+ """
+ Central database save operation.
+
+ Notes:
+ Arguments as per Django documentation.
+ Calls `self.at_<fieldname>_postsave(new)`
+ (this is a wrapper set by oobhandler:
+ self._oob_at_<fieldname>_postsave())
+
+ """
+ global_MONITOR_HANDLER
+ ifnot_MONITOR_HANDLER:
+ fromevennia.scripts.monitorhandlerimportMONITOR_HANDLERas_MONITOR_HANDLER
+
+ if_IS_SUBPROCESS:
+ # we keep a store of objects modified in subprocesses so
+ # we know to update their caches in the central process
+ globalPROC_MODIFIED_COUNT,PROC_MODIFIED_OBJS
+ PROC_MODIFIED_COUNT+=1
+ PROC_MODIFIED_OBJS[PROC_MODIFIED_COUNT]=self
+
+ if_IS_MAIN_THREAD:
+ # in main thread - normal operation
+ try:
+ super().save(*args,**kwargs)
+ exceptDatabaseError:
+ # we handle the 'update_fields did not update any rows' error that
+ # may happen due to timing issues with attributes
+ ufields_removed=kwargs.pop("update_fields",None)
+ ifufields_removed:
+ super().save(*args,**kwargs)
+ else:
+ raise
+ else:
+ # in another thread; make sure to save in reactor thread
+ def_save_callback(cls,*args,**kwargs):
+ super().save(*args,**kwargs)
+
+ callFromThread(_save_callback,self,*args,**kwargs)
+
+ ifnotself.pk:
+ # this can happen if some of the startup methods immediately
+ # delete the object (an example are Scripts that start and die immediately)
+ return
+
+ # update field-update hooks and eventual OOB watchers
+ new=False
+ if"update_fields"inkwargsandkwargs["update_fields"]:
+ # get field objects from their names
+ update_fields=(
+ self._meta.get_field(fieldname)forfieldnameinkwargs.get("update_fields")
+ )
+ else:
+ # meta.fields are already field objects; get them all
+ new=True
+ update_fields=self._meta.fields
+ forfieldinupdate_fields:
+ fieldname=field.name
+ # trigger eventual monitors
+ _MONITOR_HANDLER.at_update(self,fieldname)
+ # if a hook is defined it must be named exactly on this form
+ hookname="at_%s_postsave"%fieldname
+ ifhasattr(self,hookname)andcallable(_GA(self,hookname)):
+ _GA(self,hookname)(new)
+
+ # # if a trackerhandler is set on this object, update it with the
+ # # fieldname and the new value
+ # fieldtracker = "_oob_at_%s_postsave" % fieldname
+ # if hasattr(self, fieldtracker):
+ # _GA(self, fieldtracker)(fieldname)
+ pass
+
+
+
[docs]classWeakSharedMemoryModelBase(SharedMemoryModelBase):
+ """
+ Uses a WeakValue dictionary for caching instead of a regular one.
+
+ """
+
+ def_prepare(cls):
+ super()._prepare()
+ cls.__dbclass__.__instance_cache__=WeakValueDictionary()
+
+
+
[docs]classWeakSharedMemoryModel(SharedMemoryModel,metaclass=WeakSharedMemoryModelBase):
+ """
+ Uses a WeakValue dictionary for caching instead of a regular one
+
+ """
+
+
[docs]defflush_cache(**kwargs):
+ """
+ Flush idmapper cache. When doing so the cache will fire the
+ at_idmapper_flush hook to allow the object to optionally handle
+ its own flushing.
+
+ Uses a signal so we make sure to catch cascades.
+
+ """
+
+ defclass_hierarchy(clslist):
+ """Recursively yield a class hierarchy"""
+ forclsinclslist:
+ subclass_list=cls.__subclasses__()
+ ifsubclass_list:
+ forsubclsinclass_hierarchy(subclass_list):
+ yieldsubcls
+ else:
+ yieldcls
+
+ forclsinclass_hierarchy([SharedMemoryModel]):
+ cls.flush_instance_cache()
+ # run the python garbage collector
+ returngc.collect()
[docs]defflush_cached_instance(sender,instance,**kwargs):
+ """
+ Flush the idmapper cache only for a given instance.
+
+ """
+ # XXX: Is this the best way to make sure we can flush?
+ ifnothasattr(instance,"flush_cached_instance"):
+ return
+ sender.flush_cached_instance(instance,force=True)
[docs]defconditional_flush(max_rmem,force=False):
+ """
+ Flush the cache if the estimated memory usage exceeds `max_rmem`.
+
+ The flusher has a timeout to avoid flushing over and over
+ in particular situations (this means that for some setups
+ the memory usage will exceed the requirement and a server with
+ more memory is probably required for the given game).
+
+ Args:
+ max_rmem (int): memory-usage estimation-treshold after which
+ cache is flushed.
+ force (bool, optional): forces a flush, regardless of timeout.
+ Defaults to `False`.
+
+ """
+ globalLAST_FLUSH
+
+ defmem2cachesize(desired_rmem):
+ """
+ Estimate the size of the idmapper cache based on the memory
+ desired. This is used to optionally cap the cache size.
+
+ desired_rmem - memory in MB (minimum 50MB)
+
+ The formula is empirically estimated from usage tests (Linux)
+ and is
+ Ncache = RMEM - 35.0 / 0.0157
+ where RMEM is given in MB and Ncache is the size of the cache
+ for this memory usage. VMEM tends to be about 100MB higher
+ than RMEM for large memory usage.
+ """
+ vmem=max(desired_rmem,50.0)
+ Ncache=int(abs(float(vmem)-35.0)/0.0157)
+ returnNcache
+
+ ifnotmax_rmem:
+ # auto-flush is disabled
+ return
+
+ now=time.time()
+ ifnotLAST_FLUSH:
+ # server is just starting
+ LAST_FLUSH=now
+ return
+
+ if((now-LAST_FLUSH)<AUTO_FLUSH_MIN_INTERVAL)andnotforce:
+ # too soon after last flush.
+ logger.log_warn(
+ "Warning: Idmapper flush called more than "
+ "once in %s min interval. Check memory usage."%(AUTO_FLUSH_MIN_INTERVAL/60.0)
+ )
+ return
+
+ ifos.name=="nt":
+ # we can't look for mem info in Windows at the moment
+ return
+
+ # check actual memory usage
+ Ncache_max=mem2cachesize(max_rmem)
+ Ncache,_=cache_size()
+ actual_rmem=(
+ float(os.popen("ps -p %d -o %s | tail -1"%(os.getpid(),"rss")).read())/1000.0
+ )# resident memory
+
+ ifNcache>=Ncache_maxandactual_rmem>max_rmem*0.9:
+ # flush cache when number of objects in cache is big enough and our
+ # actual memory use is within 10% of our set max
+ flush_cache()
+ LAST_FLUSH=now
+
+
+
[docs]defcache_size(mb=True):
+ """
+ Calculate statistics about the cache.
+
+ Note: we cannot get reliable memory statistics from the cache -
+ whereas we could do `getsizof` each object in cache, the result is
+ highly imprecise and for a large number of objects the result is
+ many times larger than the actual memory usage of the entire server;
+ Python is clearly reusing memory behind the scenes that we cannot
+ catch in an easy way here. Ideas are appreciated. /Griatch
+
+ Returns:
+ total_num, {objclass:total_num, ...}
+
+ """
+ numtotal=[0]# use mutable to keep reference through recursion
+ classdict={}
+
+ defget_recurse(submodels):
+ forsubmodelinsubmodels:
+ subclasses=submodel.__subclasses__()
+ ifnotsubclasses:
+ num=len(submodel.get_all_cached_instances())
+ numtotal[0]+=num
+ classdict[submodel.__dbclass__.__name__]=num
+ else:
+ get_recurse(subclasses)
+
+ get_recurse(SharedMemoryModel.__subclasses__())
+ returnnumtotal[0],classdict
+
+ # article_list = Article.objects.all().select_related('category')
+ # last_article = article_list[0]
+ # for article in article_list[1:]:
+ # self.assertEquals(article.category2 is last_article.category2, False)
+ # last_article = article
+
+
[docs]deftestObjectDeletion(self):
+ # This must execute first so its guaranteed to be in memory.
+ list(Article.objects.all().select_related("category"))
+
+ article=Article.objects.all()[0:1].get()
+ pk=article.pk
+ article.delete()
+ self.assertEqual(pknotinArticle.__instance_cache__,True)
+"""
+Inline functions (nested form).
+
+This parser accepts nested inlinefunctions on the form
+::
+
+ $funcname(arg, arg, ...)
+
+embedded in any text where any arg can be another `$funcname{}` call.
+This functionality is turned off by default - to activate,
+`settings.INLINEFUNC_ENABLED` must be set to `True`.
+
+Each token starts with `$funcname(` where there must be no space between the
+$funcname and "(". It ends with a matched ending parentesis ")".
+
+Inside the inlinefunc definition, one can use \\\\ to escape. This is
+mainly needed for escaping commas in flowing text (which would
+otherwise be interpreted as an argument separator), or to escape `}`
+when not intended to close the function block. Enclosing text in
+matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will
+also escape *everything* within without needing to escape individual
+characters.
+
+The available inlinefuncs are defined as global-level functions in
+modules defined by `settings.INLINEFUNC_MODULES`. They are identified
+by their function name (and ignored if this name starts with `_`). They
+should be on the following form:
+::
+
+ def funcname (*args, **kwargs):
+ # ...
+
+Here, the arguments given to `$funcname(arg1,arg2)` will appear as the
+`*args` tuple. This will be populated by the arguments given to the
+inlinefunc in-game - the only part that will be available from
+in-game. `**kwargs` are not supported from in-game but are only used
+internally by Evennia to make details about the caller available to
+the function. The kwarg passed to all functions is `session`, the
+Sessionobject for the object seeing the string. This may be `None` if
+the string is sent to a non-puppetable object. The inlinefunc should
+never raise an exception.
+
+There are two reserved function names:
+
+- "nomatch": This is called if the user uses a functionname that is
+ not registered. The nomatch function will get the name of the
+ not-found function as its first argument followed by the normal
+ arguments to the given function. If not defined the default effect is
+ to print `<UNKNOWN>` to replace the unknown function.
+- "stackfull": This is called when the maximum nested function stack is reached.
+ When this happens, the original parsed string is returned and the result of
+ the `stackfull` inlinefunc is appended to the end. By default this is an
+ error message.
+
+Syntax errors, notably not completely closing all inlinefunc blocks, will lead
+to the entire string remaining unparsed.
+
+----
+
+"""
+
+importre
+importfnmatch
+importrandomasbase_random
+fromdjango.confimportsettings
+
+fromevennia.utilsimportutils,logger
+
+# The stack size is a security measure. Set to <=0 to disable.
+_STACK_MAXSIZE=settings.INLINEFUNC_STACK_MAXSIZE
+
+
+# example/testing inline functions
+
+
+
[docs]defrandom(*args,**kwargs):
+ """
+ Inlinefunc. Returns a random number between
+ 0 and 1, from 0 to a maximum value, or within a given range (inclusive).
+
+ Args:
+ minval (str, optional): Minimum value. If not given, assumed 0.
+ maxval (str, optional): Maximum value.
+
+ Keyword argumuents:
+ session (Session): Session getting the string.
+
+ Notes:
+ If either of the min/maxvalue has a '.' in it, a floating-point random
+ value will be returned. Otherwise it will be an integer value in the
+ given range.
+
+ Example:
+
+ - `$random()`
+ - `$random(5)`
+ - `$random(5, 10)`
+
+ """
+ nargs=len(args)
+ ifnargs==1:
+ # only maxval given
+ minval,maxval="0",args[0]
+ elifnargs>1:
+ minval,maxval=args[:2]
+ else:
+ minval,maxval=("0","1")
+
+ if"."inminvalor"."inmaxval:
+ # float mode
+ try:
+ minval,maxval=float(minval),float(maxval)
+ exceptValueError:
+ minval,maxval=0,1
+ return"{:.2f}".format(minval+maxval*base_random.random())
+ else:
+ # int mode
+ try:
+ minval,maxval=int(minval),int(maxval)
+ exceptValueError:
+ minval,maxval=0,1
+ returnstr(base_random.randint(minval,maxval))
+
+
+
[docs]defpad(*args,**kwargs):
+ """
+ Inlinefunc. Pads text to given width.
+
+ Args:
+ text (str, optional): Text to pad.
+ width (str, optional): Will be converted to integer. Width
+ of padding.
+ align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'.
+ fillchar (str, optional): Character used for padding. Defaults to a
+ space.
+
+ Keyword Args:
+ session (Session): Session performing the pad.
+
+ Example:
+ `$pad(text, width, align, fillchar)`
+
+ """
+ text,width,align,fillchar="",78,"c"," "
+ nargs=len(args)
+ ifnargs>0:
+ text=args[0]
+ ifnargs>1:
+ width=int(args[1])ifargs[1].strip().isdigit()else78
+ ifnargs>2:
+ align=args[2]ifargs[2]in("c","l","r")else"c"
+ ifnargs>3:
+ fillchar=args[3]
+ returnutils.pad(text,width=width,align=align,fillchar=fillchar)
+
+
+
[docs]defcrop(*args,**kwargs):
+ """
+ Inlinefunc. Crops ingoing text to given widths.
+
+ Args:
+ text (str, optional): Text to crop.
+ width (str, optional): Will be converted to an integer. Width of
+ crop in characters.
+ suffix (str, optional): End string to mark the fact that a part
+ of the string was cropped. Defaults to `[...]`.
+ Keyword Args:
+ session (Session): Session performing the crop.
+
+ Example:
+ `$crop(text, width=78, suffix='[...]')`
+
+ """
+ text,width,suffix="",78,"[...]"
+ nargs=len(args)
+ ifnargs>0:
+ text=args[0]
+ ifnargs>1:
+ width=int(args[1])ifargs[1].strip().isdigit()else78
+ ifnargs>2:
+ suffix=args[2]
+ returnutils.crop(text,width=width,suffix=suffix)
+
+
+
[docs]defspace(*args,**kwargs):
+ """
+ Inlinefunc. Inserts an arbitrary number of spaces. Defaults to 4 spaces.
+
+ Args:
+ spaces (int, optional): The number of spaces to insert.
+
+ Keyword Args:
+ session (Session): Session performing the crop.
+
+ Example:
+ `$space(20)`
+
+ """
+ width=4
+ ifargs:
+ width=abs(int(args[0]))ifargs[0].strip().isdigit()else4
+ return" "*width
+
+
+
[docs]defclr(*args,**kwargs):
+ """
+ Inlinefunc. Colorizes nested text.
+
+ Args:
+ startclr (str, optional): An ANSI color abbreviation without the
+ prefix `|`, such as `r` (red foreground) or `[r` (red background).
+ text (str, optional): Text
+ endclr (str, optional): The color to use at the end of the string. Defaults
+ to `|n` (reset-color).
+ Keyword Args:
+ session (Session): Session object triggering inlinefunc.
+
+ Example:
+ `$clr(startclr, text, endclr)`
+
+ """
+ text=""
+ nargs=len(args)
+ ifnargs>0:
+ color=args[0].strip()
+ ifnargs>1:
+ text=args[1]
+ text="|"+color+text
+ ifnargs>2:
+ text+="|"+args[2].strip()
+ else:
+ text+="|n"
+ returntext
[docs]defnomatch(name,*args,**kwargs):
+ """
+ Default implementation of nomatch returns the function as-is as a string.
+
+ """
+ kwargs.pop("inlinefunc_stack_depth",None)
+ kwargs.pop("session")
+
+ return"${name}({args}{kwargs})".format(
+ name=name,
+ args=",".join(args),
+ kwargs=",".join("{}={}".format(key,val)forkey,valinkwargs.items()),
+ )
+
+
+_INLINE_FUNCS={}
+
+# we specify a default nomatch function to use if no matching func was
+# found. This will be overloaded by any nomatch function defined in
+# the imported modules.
+_DEFAULT_FUNCS={
+ "nomatch":lambda*args,**kwargs:"<UNKNOWN>",
+ "stackfull":lambda*args,**kwargs:"\n (not parsed: ",
+}
+
+_INLINE_FUNCS.update(_DEFAULT_FUNCS)
+
+# load custom inline func modules.
+formoduleinutils.make_iter(settings.INLINEFUNC_MODULES):
+ try:
+ _INLINE_FUNCS.update(utils.callables_from_module(module))
+ exceptImportErroraserr:
+ ifmodule=="server.conf.inlinefuncs":
+ # a temporary warning since the default module changed name
+ raiseImportError(
+ "Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should "
+ "be renamed to mygame/server/conf/inlinefuncs.py (note "
+ "the S at the end)."%err
+ )
+ else:
+ raise
+
+
+# regex definitions
+
+_RE_STARTTOKEN=re.compile(r"(?<!\\)\$(\w+)\(")# unescaped $funcname( (start of function call)
+
+# note: this regex can be experimented with at https://regex101.com/r/kGR3vE/2
+_RE_TOKEN=re.compile(
+ r"""
+ (?<!\\)\'\'\'(?P<singlequote>.*?)(?<!\\)\'\'\'| # single-triplets escape all inside
+ (?<!\\)\"\"\"(?P<doublequote>.*?)(?<!\\)\"\"\"| # double-triplets escape all inside
+ (?P<comma>(?<!\\)\,)| # , (argument sep)
+ (?P<end>(?<!\\)\))| # ) (possible end of func call)
+ (?P<leftparens>(?<!\\)\()| # ( (lone left-parens)
+ (?P<start>(?<!\\)\$\w+\()| # $funcname (start of func call)
+ (?P<escaped> # escaped tokens to re-insert sans backslash
+ \\\'|\\\"|\\\)|\\\$\w+\(|\\\()|
+ (?P<rest> # everything else to re-insert verbatim
+ \$(?!\w+\()|\'|\"|\\|[^),$\'\"\\\(]+)""",
+ re.UNICODE|re.IGNORECASE|re.VERBOSE|re.DOTALL,
+)
+
+# Cache for function lookups.
+_PARSING_CACHE=utils.LimitedSizeOrderedDict(size_limit=1000)
+
+
+
[docs]classParseStack(list):
+ """
+ Custom stack that always concatenates strings together when the
+ strings are added next to one another. Tuples are stored
+ separately and None is used to mark that a string should be broken
+ up into a new chunk. Below is the resulting stack after separately
+ appending 3 strings, None, 2 strings, a tuple and finally 2
+ strings:
+
+ [string + string + string,
+ None
+ string + string,
+ tuple,
+ string + string]
+
+ """
+
+
[docs]def__init__(self,*args,**kwargs):
+ super().__init__(*args,**kwargs)
+ # always start stack with the empty string
+ list.append(self,"")
+ # indicates if the top of the stack is a string or not
+ self._string_last=True
[docs]defappend(self,item):
+ """
+ The stack will merge strings, add other things as normal
+ """
+ ifisinstance(item,str):
+ ifself._string_last:
+ self[-1]+=item
+ else:
+ list.append(self,item)
+ self._string_last=True
+ else:
+ # everything else is added as normal
+ list.append(self,item)
+ self._string_last=False
[docs]defparse_inlinefunc(string,strip=False,available_funcs=None,stacktrace=False,**kwargs):
+ """
+ Parse the incoming string.
+
+ Args:
+ string (str): The incoming string to parse.
+ strip (bool, optional): Whether to strip function calls rather than
+ execute them.
+ available_funcs (dict, optional): Define an alternative source of functions to parse for.
+ If unset, use the functions found through `settings.INLINEFUNC_MODULES`.
+ stacktrace (bool, optional): If set, print the stacktrace to log.
+ Keyword Args:
+ session (Session): This is sent to this function by Evennia when triggering
+ it. It is passed to the inlinefunc.
+ kwargs (any): All other kwargs are also passed on to the inlinefunc.
+
+
+ """
+ global_PARSING_CACHE
+ usecache=False
+ ifnotavailable_funcs:
+ available_funcs=_INLINE_FUNCS
+ usecache=True
+ else:
+ # make sure the default keys are available, but also allow overriding
+ tmp=_DEFAULT_FUNCS.copy()
+ tmp.update(available_funcs)
+ available_funcs=tmp
+
+ ifusecacheandstringin_PARSING_CACHE:
+ # stack is already cached
+ stack=_PARSING_CACHE[string]
+ elifnot_RE_STARTTOKEN.search(string):
+ # if there are no unescaped start tokens at all, return immediately.
+ returnstring
+ else:
+ # no cached stack; build a new stack and continue
+ stack=ParseStack()
+
+ # process string on stack
+ ncallable=0
+ nlparens=0
+ nvalid=0
+
+ ifstacktrace:
+ out="STRING: {} =>".format(string)
+ print(out)
+ logger.log_info(out)
+
+ formatchin_RE_TOKEN.finditer(string):
+ gdict=match.groupdict()
+
+ ifstacktrace:
+ out=" MATCH: {}".format({key:valforkey,valingdict.items()ifval})
+ print(out)
+ logger.log_info(out)
+
+ ifgdict["singlequote"]:
+ stack.append(gdict["singlequote"])
+ elifgdict["doublequote"]:
+ stack.append(gdict["doublequote"])
+ elifgdict["leftparens"]:
+ # we have a left-parens inside a callable
+ ifncallable:
+ nlparens+=1
+ stack.append("(")
+ elifgdict["end"]:
+ ifnlparens>0:
+ nlparens-=1
+ stack.append(")")
+ continue
+ ifncallable<=0:
+ stack.append(")")
+ continue
+ args=[]
+ whilestack:
+ operation=stack.pop()
+ ifcallable(operation):
+ ifnotstrip:
+ stack.append((operation,[argforarginreversed(args)]))
+ ncallable-=1
+ break
+ else:
+ args.append(operation)
+ elifgdict["start"]:
+ funcname=_RE_STARTTOKEN.match(gdict["start"]).group(1)
+ try:
+ # try to fetch the matching inlinefunc from storage
+ stack.append(available_funcs[funcname])
+ nvalid+=1
+ exceptKeyError:
+ stack.append(available_funcs["nomatch"])
+ stack.append(funcname)
+ stack.append(None)
+ ncallable+=1
+ elifgdict["escaped"]:
+ # escaped tokens
+ token=gdict["escaped"].lstrip("\\")
+ stack.append(token)
+ elifgdict["comma"]:
+ ifncallable>0:
+ # commas outside strings and inside a callable are
+ # used to mark argument separation - we use None
+ # in the stack to indicate such a separation.
+ stack.append(None)
+ else:
+ # no callable active - just a string
+ stack.append(",")
+ else:
+ # the rest
+ stack.append(gdict["rest"])
+
+ ifncallable>0:
+ # this means not all inlinefuncs were complete
+ returnstring
+
+ if_STACK_MAXSIZE>0and_STACK_MAXSIZE<nvalid:
+ # if stack is larger than limit, throw away parsing
+ returnstring+available_funcs["stackfull"](*args,**kwargs)
+ elifusecache:
+ # cache the stack - we do this also if we don't check the cache above
+ _PARSING_CACHE[string]=stack
+
+ # run the stack recursively
+ def_run_stack(item,depth=0):
+ retval=item
+ ifisinstance(item,tuple):
+ ifstrip:
+ return""
+ else:
+ func,arglist=item
+ args=[""]
+ forarginarglist:
+ ifargisNone:
+ # an argument-separating comma - start a new arg
+ args.append("")
+ else:
+ # all other args should merge into one string
+ args[-1]+=_run_stack(arg,depth=depth+1)
+ # execute the inlinefunc at this point or strip it.
+ kwargs["inlinefunc_stack_depth"]=depth
+ retval=""ifstripelsefunc(*args,**kwargs)
+ returnutils.to_str(retval)
+
+ retval="".join(_run_stack(item)foriteminstack)
+ ifstacktrace:
+ out="STACK: \n{} => {}\n".format(stack,retval)
+ print(out)
+ logger.log_info(out)
+
+ # execute the stack
+ returnretval
+
+
+
[docs]defraw(string):
+ """
+ Escape all inlinefuncs in a string so they won't get parsed.
+
+ Args:
+ string (str): String with inlinefuncs to escape.
+ """
+
+ def_escape(match):
+ return"\\"+match.group(0)
+
+ return_RE_STARTTOKEN.sub(_escape,string)
+
+
+#
+# Nick templating
+#
+
+
+"""
+This supports the use of replacement templates in nicks:
+
+This happens in two steps:
+
+1) The user supplies a template that is converted to a regex according
+ to the unix-like templating language.
+2) This regex is tested against nicks depending on which nick replacement
+ strategy is considered (most commonly inputline).
+3) If there is a template match and there are templating markers,
+ these are replaced with the arguments actually given.
+
+@desc $1 $2 $3
+
+This will be converted to the following regex:
+
+\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+)
+
+Supported template markers (through fnmatch)
+ * matches anything (non-greedy) -> .*?
+ ? matches any single character ->
+ [seq] matches any entry in sequence
+ [!seq] matches entries not in sequence
+Custom arg markers
+ $N argument position (1-99)
+
+"""
+_RE_NICK_ARG=re.compile(r"\\(\$)([1-9][0-9]?)")
+_RE_NICK_TEMPLATE_ARG=re.compile(r"(\$)([1-9][0-9]?)")
+_RE_NICK_SPACE=re.compile(r"\\ ")
+
+
+
[docs]definitialize_nick_templates(in_template,out_template):
+ """
+ Initialize the nick templates for matching and remapping a string.
+
+ Args:
+ in_template (str): The template to be used for nick recognition.
+ out_template (str): The template to be used to replace the string
+ matched by the `in_template`.
+
+ Returns:
+ regex, template (regex, str): Regex to match against strings and a
+ template with markers `{arg1}`, `{arg2}`, etc for replacement using the
+ standard `.format` method.
+
+ Raises:
+ inlinefuncs.NickTemplateInvalid: If the in/out template does not have a matching
+ number of $args.
+
+ """
+ # create the regex for in_template
+ regex_string=fnmatch.translate(in_template)
+ n_inargs=len(_RE_NICK_ARG.findall(regex_string))
+ regex_string=_RE_NICK_SPACE.sub("\s+",regex_string)
+ regex_string=_RE_NICK_ARG.sub(lambdam:"(?P<arg%s>.+?)"%m.group(2),regex_string)
+
+ # create the out_template
+ template_string=_RE_NICK_TEMPLATE_ARG.sub(lambdam:"{arg%s}"%m.group(2),out_template)
+
+ # validate the tempaltes - they should at least have the same number of args
+ n_outargs=len(_RE_NICK_TEMPLATE_ARG.findall(out_template))
+ ifn_inargs!=n_outargs:
+ raiseNickTemplateInvalid
+
+ returnre.compile(regex_string),template_string
+
+
+
[docs]defparse_nick_template(string,template_regex,outtemplate):
+ """
+ Parse a text using a template and map it to another template
+
+ Args:
+ string (str): The input string to processj
+ template_regex (regex): A template regex created with
+ initialize_nick_template.
+ outtemplate (str): The template to which to map the matches
+ produced by the template_regex. This should have $1, $2,
+ etc to match the regex.
+
+ """
+ match=template_regex.match(string)
+ ifmatch:
+ returnouttemplate.format(**match.groupdict())
+ returnstring
+"""
+Logging facilities
+
+These are thin wrappers on top of Twisted's logging facilities; logs
+are all directed either to stdout (if Evennia is running in
+interactive mode) or to $GAME_DIR/server/logs.
+
+The log_file() function uses its own threading system to log to
+arbitrary files in $GAME_DIR/server/logs.
+
+Note: All logging functions have two aliases, log_type() and
+log_typemsg(). This is for historical, back-compatible reasons.
+
+"""
+
+
+importos
+importtime
+importglob
+fromdatetimeimportdatetime
+fromtracebackimportformat_exc
+fromtwisted.pythonimportlog,logfile
+fromtwisted.pythonimportutilastwisted_util
+fromtwisted.internet.threadsimportdeferToThread
+
+
+_LOGDIR=None
+_LOG_ROTATE_SIZE=None
+_TIMEZONE=None
+_CHANNEL_LOG_NUM_TAIL_LINES=None
+
+
+# logging overrides
+
+
+
[docs]deftimeformat(when=None):
+ """
+ This helper function will format the current time in the same
+ way as the twisted logger does, including time zone info. Only
+ difference from official logger is that we only use two digits
+ for the year and don't show timezone for CET times.
+
+ Args:
+ when (int, optional): This is a time in POSIX seconds on the form
+ given by time.time(). If not given, this function will
+ use the current time.
+
+ Returns:
+ timestring (str): A formatted string of the given time.
+ """
+ when=whenifwhenelsetime.time()
+
+ # time zone offset: UTC - the actual offset
+ tz_offset=datetime.utcfromtimestamp(when)-datetime.fromtimestamp(when)
+ tz_offset=tz_offset.days*86400+tz_offset.seconds
+ # correct given time to utc
+ when=datetime.utcfromtimestamp(when-tz_offset)
+
+ iftz_offset==0:
+ tz=""
+ else:
+ tz_hour=abs(int(tz_offset//3600))
+ tz_mins=abs(int(tz_offset//60%60))
+ tz_sign="-"iftz_offset>=0else"+"
+ tz="%s%02d%s"%(tz_sign,tz_hour,(":%02d"%tz_minsiftz_minselse""))
+
+ return"%d-%02d-%02d%02d:%02d:%02d%s"%(
+ when.year-2000,
+ when.month,
+ when.day,
+ when.hour,
+ when.minute,
+ when.second,
+ tz,
+ )
+
+
+
[docs]classWeeklyLogFile(logfile.DailyLogFile):
+ """
+ Log file that rotates once per week by default. Overrides key methods to change format.
+
+ """
+
+
[docs]def__init__(self,name,directory,defaultMode=None,day_rotation=7,max_size=1000000):
+ """
+ Args:
+ name (str): Name of log file.
+ directory (str): Directory holding the file.
+ defaultMode (str): Permissions used to create file. Defaults to
+ current permissions of this file if it exists.
+ day_rotation (int): How often to rotate the file.
+ max_size (int): Max size of log file before rotation (regardless of
+ time). Defaults to 1M.
+
+ """
+ self.day_rotation=day_rotation
+ self.max_size=max_size
+ self.size=0
+ logfile.DailyLogFile.__init__(self,name,directory,defaultMode=defaultMode)
[docs]defshouldRotate(self):
+ """Rotate when the date has changed since last write"""
+ # all dates here are tuples (year, month, day)
+ now=self.toDate()
+ then=self.lastDate
+ return(
+ now[0]>then[0]
+ ornow[1]>then[1]
+ ornow[2]>(then[2]+self.day_rotation)
+ orself.size>=self.max_size
+ )
+
+
[docs]defsuffix(self,tupledate):
+ """Return the suffix given a (year, month, day) tuple or unixtime.
+ Format changed to have 03 for march instead of 3 etc (retaining unix
+ file order)
+
+ If we get duplicate suffixes in location (due to hitting size limit),
+ we append __1, __2 etc.
+
+ Examples:
+ server.log.2020_01_29
+ server.log.2020_01_29__1
+ server.log.2020_01_29__2
+ """
+ suffix=""
+ copy_suffix=0
+ whileTrue:
+ try:
+ suffix="_".join(["{:02d}".format(part)forpartintupledate])
+ exceptException:
+ # try taking a float unixtime
+ suffix="_".join(["{:02d}".format(part)forpartinself.toDate(tupledate)])
+
+ suffix+=f"__{copy_suffix}"ifcopy_suffixelse""
+
+ ifos.path.exists(f"{self.path}.{suffix}"):
+ # Append a higher copy_suffix to try to break the tie (starting from 2)
+ copy_suffix+=1
+ else:
+ break
+ returnsuffix
+
+
[docs]defwrite(self,data):
+ "Write data to log file"
+ logfile.BaseLogFile.write(self,data)
+ self.lastDate=max(self.lastDate,self.toDate())
+ self.size+=len(data)
[docs]deflog_msg(msg):
+ """
+ Wrapper around log.msg call to catch any exceptions that might
+ occur in logging. If an exception is raised, we'll print to
+ stdout instead.
+
+ Args:
+ msg: The message that was passed to log.msg
+
+ """
+ try:
+ log.msg(msg)
+ exceptException:
+ print("Exception raised while writing message to log. Original message: %s"%msg)
+
+
+
[docs]deflog_trace(errmsg=None):
+ """
+ Log a traceback to the log. This should be called from within an
+ exception.
+
+ Args:
+ errmsg (str, optional): Adds an extra line with added info
+ at the end of the traceback in the log.
+
+ """
+ tracestring=format_exc()
+ try:
+ iftracestring:
+ forlineintracestring.splitlines():
+ log.msg("[::] %s"%line)
+ iferrmsg:
+ try:
+ errmsg=str(errmsg)
+ exceptExceptionase:
+ errmsg=str(e)
+ forlineinerrmsg.splitlines():
+ log_msg("[EE] %s"%line)
+ exceptException:
+ log_msg("[EE] %s"%errmsg)
+
+
+log_tracemsg=log_trace
+
+
+
[docs]deflog_err(errmsg):
+ """
+ Prints/logs an error message to the server log.
+
+ Args:
+ errmsg (str): The message to be logged.
+
+ """
+ try:
+ errmsg=str(errmsg)
+ exceptExceptionase:
+ errmsg=str(e)
+ forlineinerrmsg.splitlines():
+ log_msg("[EE] %s"%line)
[docs]deflog_server(servermsg):
+ """
+ This is for the Portal to log captured Server stdout messages (it's
+ usually only used during startup, before Server log is open)
+
+ """
+ try:
+ servermsg=str(servermsg)
+ exceptExceptionase:
+ servermsg=str(e)
+ forlineinservermsg.splitlines():
+ log_msg("[Server] %s"%line)
+
+
+
[docs]deflog_warn(warnmsg):
+ """
+ Prints/logs any warnings that aren't critical but should be noted.
+
+ Args:
+ warnmsg (str): The message to be logged.
+
+ """
+ try:
+ warnmsg=str(warnmsg)
+ exceptExceptionase:
+ warnmsg=str(e)
+ forlineinwarnmsg.splitlines():
+ log_msg("[WW] %s"%line)
[docs]deflog_info(infomsg):
+ """
+ Prints any generic debugging/informative info that should appear in the log.
+
+ infomsg: (string) The message to be logged.
+ """
+ try:
+ infomsg=str(infomsg)
+ exceptExceptionase:
+ infomsg=str(e)
+ forlineininfomsg.splitlines():
+ log_msg("[..] %s"%line)
+
+
+log_infomsg=log_info
+
+
+
[docs]deflog_dep(depmsg):
+ """
+ Prints a deprecation message.
+
+ Args:
+ depmsg (str): The deprecation message to log.
+ """
+ try:
+ depmsg=str(depmsg)
+ exceptExceptionase:
+ depmsg=str(e)
+ forlineindepmsg.splitlines():
+ log_msg("[DP] %s"%line)
+
+
+log_depmsg=log_dep
+
+
+
[docs]deflog_sec(secmsg):
+ """
+ Prints a security-related message.
+
+ Args:
+ secmsg (str): The security message to log.
+ """
+ try:
+ secmsg=str(secmsg)
+ exceptExceptionase:
+ secmsg=str(e)
+ forlineinsecmsg.splitlines():
+ log_msg("[SS] %s"%line)
[docs]classEvenniaLogFile(logfile.LogFile):
+ """
+ A rotating logfile based off Twisted's LogFile. It overrides
+ the LogFile's rotate method in order to append some of the last
+ lines of the previous log to the start of the new log, in order
+ to preserve a continuous chat history for channel log files.
+ """
+
+ # we delay import of settings to keep logger module as free
+ # from django as possible.
+ global_CHANNEL_LOG_NUM_TAIL_LINES
+ if_CHANNEL_LOG_NUM_TAIL_LINESisNone:
+ fromdjango.confimportsettings
+
+ _CHANNEL_LOG_NUM_TAIL_LINES=settings.CHANNEL_LOG_NUM_TAIL_LINES
+ num_lines_to_append=_CHANNEL_LOG_NUM_TAIL_LINES
+
+
[docs]defrotate(self):
+ """
+ Rotates our log file and appends some number of lines from
+ the previous log to the start of the new one.
+ """
+ append_tail=self.num_lines_to_append>0
+ ifnotappend_tail:
+ logfile.LogFile.rotate(self)
+ return
+ lines=tail_log_file(self.path,0,self.num_lines_to_append)
+ super().rotate()
+ forlineinlines:
+ self.write(line)
+
+
[docs]defseek(self,*args,**kwargs):
+ """
+ Convenience method for accessing our _file attribute's seek method,
+ which is used in tail_log_function.
+ Args:
+ *args: Same args as file.seek
+ **kwargs: Same kwargs as file.seek
+ """
+ returnself._file.seek(*args,**kwargs)
+
+
[docs]defreadlines(self,*args,**kwargs):
+ """
+ Convenience method for accessing our _file attribute's readlines method,
+ which is used in tail_log_function.
+ Args:
+ *args: same args as file.readlines
+ **kwargs: same kwargs as file.readlines
+
+ Returns:
+ lines (list): lines from our _file attribute.
+ """
+ lines=[]
+ forlineinself._file.readlines(*args,**kwargs):
+ try:
+ lines.append(line.decode("utf-8"))
+ exceptUnicodeDecodeError:
+ try:
+ lines.append(str(line))
+ exceptException:
+ lines.append("")
+ returnlines
+
+
+_LOG_FILE_HANDLES={}# holds open log handles
+_LOG_FILE_HANDLE_COUNTS={}
+_LOG_FILE_HANDLE_RESET=500
+
+
+def_open_log_file(filename):
+ """
+ Helper to open the log file (always in the log dir) and cache its
+ handle. Will create a new file in the log dir if one didn't
+ exist.
+
+ To avoid keeping the filehandle open indefinitely we reset it every
+ _LOG_FILE_HANDLE_RESET accesses. This may help resolve issues for very
+ long uptimes and heavy log use.
+
+ """
+ # we delay import of settings to keep logger module as free
+ # from django as possible.
+ global_LOG_FILE_HANDLES,_LOG_FILE_HANDLE_COUNTS,_LOGDIR,_LOG_ROTATE_SIZE
+ ifnot_LOGDIR:
+ fromdjango.confimportsettings
+
+ _LOGDIR=settings.LOG_DIR
+ _LOG_ROTATE_SIZE=settings.CHANNEL_LOG_ROTATE_SIZE
+
+ filename=os.path.join(_LOGDIR,filename)
+ iffilenamein_LOG_FILE_HANDLES:
+ _LOG_FILE_HANDLE_COUNTS[filename]+=1
+ if_LOG_FILE_HANDLE_COUNTS[filename]>_LOG_FILE_HANDLE_RESET:
+ # close/refresh handle
+ _LOG_FILE_HANDLES[filename].close()
+ del_LOG_FILE_HANDLES[filename]
+ else:
+ # return cached handle
+ return_LOG_FILE_HANDLES[filename]
+ try:
+ filehandle=EvenniaLogFile.fromFullPath(filename,rotateLength=_LOG_ROTATE_SIZE)
+ # filehandle = open(filename, "a+") # append mode + reading
+ _LOG_FILE_HANDLES[filename]=filehandle
+ _LOG_FILE_HANDLE_COUNTS[filename]=0
+ returnfilehandle
+ exceptIOError:
+ log_trace()
+ returnNone
+
+
+
[docs]deflog_file(msg,filename="game.log"):
+ """
+ Arbitrary file logger using threads.
+
+ Args:
+ msg (str): String to append to logfile.
+ filename (str, optional): Defaults to 'game.log'. All logs
+ will appear in the logs directory and log entries will start
+ on new lines following datetime info.
+
+ """
+
+ defcallback(filehandle,msg):
+ """Writing to file and flushing result"""
+ msg="\n%s [-] %s"%(timeformat(),msg.strip())
+ filehandle.write(msg)
+ # since we don't close the handle, we need to flush
+ # manually or log file won't be written to until the
+ # write buffer is full.
+ filehandle.flush()
+
+ deferrback(failure):
+ """Catching errors to normal log"""
+ log_trace()
+
+ # save to server/logs/ directory
+ filehandle=_open_log_file(filename)
+ iffilehandle:
+ deferToThread(callback,filehandle,msg).addErrback(errback)
+
+
+
[docs]deftail_log_file(filename,offset,nlines,callback=None):
+ """
+ Return the tail of the log file.
+
+ Args:
+ filename (str): The name of the log file, presumed to be in
+ the Evennia log dir.
+ offset (int): The line offset *from the end of the file* to start
+ reading from. 0 means to start at the latest entry.
+ nlines (int): How many lines to return, counting backwards
+ from the offset. If file is shorter, will get all lines.
+ callback (callable, optional): A function to manage the result of the
+ asynchronous file access. This will get a list of lines. If unset,
+ the tail will happen synchronously.
+
+ Returns:
+ lines (deferred or list): This will be a deferred if `callable` is given,
+ otherwise it will be a list with The nline entries from the end of the file, or
+ all if the file is shorter than nlines.
+
+ """
+
+ defseek_file(filehandle,offset,nlines,callback):
+ """step backwards in chunks and stop only when we have enough lines"""
+ lines_found=[]
+ buffer_size=4098
+ block_count=-1
+ whilelen(lines_found)<(offset+nlines):
+ try:
+ # scan backwards in file, starting from the end
+ filehandle.seek(block_count*buffer_size,os.SEEK_END)
+ exceptIOError:
+ # file too small for this seek, take what we've got
+ filehandle.seek(0)
+ lines_found=filehandle.readlines()
+ break
+ lines_found=filehandle.readlines()
+ block_count-=1
+ # return the right number of lines
+ lines_found=lines_found[-nlines-offset:-offsetifoffsetelseNone]
+ ifcallback:
+ callback(lines_found)
+ returnNone
+ else:
+ returnlines_found
+
+ deferrback(failure):
+ """Catching errors to normal log"""
+ log_trace()
+
+ filehandle=_open_log_file(filename)
+ iffilehandle:
+ ifcallback:
+ returndeferToThread(seek_file,filehandle,offset,nlines,callback).addErrback(
+ errback
+ )
+ else:
+ returnseek_file(filehandle,offset,nlines,callback)
+ else:
+ returnNone
+"""
+Option classes store user- or server Options in a generic way
+while also providing validation.
+
+"""
+
+importdatetime
+fromevenniaimportlogger
+fromevennia.utils.ansiimportstrip_ansi
+fromevennia.utils.validatorfuncsimport_TZ_DICT
+fromevennia.utils.utilsimportcrop
+fromevennia.utilsimportvalidatorfuncs
+
+
+
[docs]classBaseOption:
+ """
+ Abstract Class to deal with encapsulating individual Options. An Option has
+ a name/key, a description to display in relevant commands and menus, and a
+ default value. It saves to the owner's Attributes using its Handler's save
+ category.
+
+ Designed to be extremely overloadable as some options can be cantankerous.
+
+ Properties:
+ valid: Shortcut to the loaded VALID_HANDLER.
+ validator_key (str): The key of the Validator this uses.
+
+ """
+
+ def__str__(self):
+ return"<Option {key}: {value}>".format(key=self.key,value=crop(str(self.value),width=10))
+
+ def__repr__(self):
+ returnstr(self)
+
+
[docs]def__init__(self,handler,key,description,default):
+ """
+
+ Args:
+ handler (OptionHandler): The OptionHandler that 'owns' this Option.
+ key (str): The name this will be used for storage in a dictionary.
+ Must be unique per OptionHandler.
+ description (str): What this Option's text will show in commands and menus.
+ default: A default value for this Option.
+
+ """
+ self.handler=handler
+ self.key=key
+ self.default_value=default
+ self.description=description
+
+ # Value Storage contains None until the Option is loaded.
+ self.value_storage=None
+
+ # And it's not loaded until it's called upon to spit out its contents.
+ self.loaded=False
[docs]defset(self,value,**kwargs):
+ """
+ Takes user input and stores appropriately. This method allows for
+ passing extra instructions into the validator.
+
+ Args:
+ value (str): The new value of this Option.
+ kwargs (any): Any kwargs will be passed into
+ `self.validate(value, **kwargs)` and `self.save(**kwargs)`.
+
+ """
+ final_value=self.validate(value,**kwargs)
+ self.value_storage=final_value
+ self.loaded=True
+ self.save(**kwargs)
+
+
[docs]defload(self):
+ """
+ Takes the provided save data, validates it, and gets this Option ready to use.
+
+ Returns:
+ Boolean: Whether loading was successful.
+
+ """
+ loadfunc=self.handler.loadfunc
+ load_kwargs=self.handler.load_kwargs
+
+ try:
+ self.value_storage=self.deserialize(
+ loadfunc(self.key,default=self.default_value,**load_kwargs)
+ )
+ exceptException:
+ logger.log_trace()
+ returnFalse
+ self.loaded=True
+ returnTrue
+
+
[docs]defsave(self,**kwargs):
+ """
+ Stores the current value using `.handler.save_handler(self.key, value, **kwargs)`
+ where kwargs are a combination of those passed into this function and the
+ ones specified by the OptionHandler.
+
+ Keyword Args:
+ any (any): Not used by default. These are passed in from self.set
+ and allows the option to let the caller customize saving by
+ overriding or extend the default save kwargs
+
+ """
+ value=self.serialize()
+ save_kwargs={**self.handler.save_kwargs,**kwargs}
+ savefunc=self.handler.savefunc
+ savefunc(self.key,value=value,**save_kwargs)
+
+
[docs]defdeserialize(self,save_data):
+ """
+ Perform sanity-checking on the save data as it is loaded from storage.
+ This isn't the same as what validator-functions provide (those work on
+ user input). For example, save data might be a timedelta or a list or
+ some other object.
+
+ Args:
+ save_data: The data to check.
+
+ Returns:
+ any (any): Whatever the Option needs to track, like a string or a
+ datetime. The display hook is responsible for what is actually
+ displayed to user.
+ """
+ returnsave_data
+
+
[docs]defserialize(self):
+ """
+ Serializes the save data for Attribute storage.
+
+ Returns:
+ any (any): Whatever is best for storage.
+
+ """
+ returnself.value_storage
+
+
[docs]defvalidate(self,value,**kwargs):
+ """
+ Validate user input, which is presumed to be a string.
+
+ Args:
+ value (str): User input.
+ account (AccountDB): The Account that is performing the validation.
+ This is necessary because of other settings which may affect the
+ check, such as an Account's timezone affecting how their datetime
+ entries are processed.
+ Returns:
+ any (any): The results of the validation.
+ Raises:
+ ValidationError: If input value failed validation.
+
+ """
+ returnvalidatorfuncs.text(value,option_key=self.key,**kwargs)
+
+
[docs]defdisplay(self,**kwargs):
+ """
+ Renders the Option's value as something pretty to look at.
+
+ Keyword Args:
+ any (any): These are options passed by the caller to potentially
+ customize display dynamically.
+
+ Returns:
+ str: How the stored value should be projected to users (e.g. a raw
+ timedelta is pretty ugly).
+
+ """
+ returnself.value
[docs]defdeserialize(self,save_data):
+ ifisinstance(save_data,int):
+ returndatetime.datetime.utcfromtimestamp(save_data)
+ raiseValueError(f"{self.key} expected UTC Datetime in EPOCH format, got '{save_data}'")
[docs]classInMemorySaveHandler(object):
+ """
+ Fallback SaveHandler, implementing a minimum of the required save mechanism
+ and storing data in memory.
+
+ """
+
+
[docs]classOptionHandler(object):
+ """
+ This is a generic Option handler. Retrieve options either as properties on
+ this handler or by using the .get method.
+
+ This is used for Account.options but it could be used by Scripts or Objects
+ just as easily. All it needs to be provided is an options_dict.
+
+ """
+
+
[docs]def__init__(
+ self,
+ obj,
+ options_dict=None,
+ savefunc=None,
+ loadfunc=None,
+ save_kwargs=None,
+ load_kwargs=None,
+ ):
+ """
+ Initialize an OptionHandler.
+
+ Args:
+ obj (object): The object this handler sits on. This is usually a TypedObject.
+ options_dict (dict): A dictionary of option keys, where the values
+ are options. The format of those tuples is: ('key', "Description to
+ show", 'option_type', <default value>)
+ savefunc (callable): A callable for all options to call when saving itself.
+ It will be called as `savefunc(key, value, **save_kwargs)`. A common one
+ to pass would be AttributeHandler.add.
+ loadfunc (callable): A callable for all options to call when loading data into
+ itself. It will be called as `loadfunc(key, default=default, **load_kwargs)`.
+ A common one to pass would be AttributeHandler.get.
+ save_kwargs (any): Optional extra kwargs to pass into `savefunc` above.
+ load_kwargs (any): Optional extra kwargs to pass into `loadfunc` above.
+ Notes:
+ Both loadfunc and savefunc must be specified. If only one is given, the other
+ will be ignored and in-memory storage will be used.
+
+ """
+ self.obj=obj
+ self.options_dict={}ifoptions_dictisNoneelseoptions_dict
+
+ ifnotsavefuncandloadfunc:
+ self._in_memory_handler=InMemorySaveHandler()
+ savefunc=InMemorySaveHandler.add
+ loadfunc=InMemorySaveHandler.get
+ self.savefunc=savefunc
+ self.loadfunc=loadfunc
+ self.save_kwargs={}ifsave_kwargsisNoneelsesave_kwargs
+ self.load_kwargs={}ifload_kwargsisNoneelseload_kwargs
+
+ # This dictionary stores the in-memory Options objects by their key for
+ # quick lookup.
+ self.options={}
+
+ def__getattr__(self,key):
+ """
+ Allow for obj.options.key
+
+ """
+ returnself.get(key)
+
+ def__setattr__(self,key,value):
+ """
+ Allow for obj.options.key = value
+
+ But we must be careful to avoid infinite loops!
+
+ """
+ try:
+ ifkeyin_GA(self,"options_dict"):
+ _GA(self,"set")(key,value)
+ exceptAttributeError:
+ pass
+ _SA(self,key,value)
+
+ def_load_option(self,key):
+ """
+ Loads option on-demand if it has not been loaded yet.
+
+ Args:
+ key (str): The option being loaded.
+
+ Returns:
+
+ """
+ desc,clsname,default_val=self.options_dict[key]
+ loaded_option=OPTION_CLASSES.get(clsname)(self,key,desc,default_val)
+ # store the value for future easy access
+ self.options[key]=loaded_option
+ returnloaded_option
+
+
[docs]defget(self,key,default=None,return_obj=False,raise_error=False):
+ """
+ Retrieves an Option stored in the handler. Will load it if it doesn't exist.
+
+ Args:
+ key (str): The option key to retrieve.
+ default (any): What to return if the option is defined.
+ return_obj (bool, optional): If True, returns the actual option
+ object instead of its value.
+ raise_error (bool, optional): Raise Exception if key is not found in options.
+ Returns:
+ option_value (any or Option): An option value the Option itself.
+ Raises:
+ KeyError: If option is not defined.
+
+ """
+ ifkeynotinself.options_dict:
+ ifraise_error:
+ raiseKeyError("Option not found!")
+ returndefault
+ # get the options or load/recache it
+ op_found=self.options.get(key)orself._load_option(key)
+ returnop_foundifreturn_objelseop_found.value
+
+
[docs]defset(self,key,value,**kwargs):
+ """
+ Change an individual option.
+
+ Args:
+ key (str): The key of an option that can be changed. Allows partial matching.
+ value (str): The value that should be checked, coerced, and stored.:
+ kwargs (any, optional): These are passed into the Option's validation function,
+ save function and display function and allows to customize either.
+
+ Returns:
+ value (any): Value stored in option, after validation.
+
+ """
+ ifnotkey:
+ raiseValueError("Option field blank!")
+ match=string_partial_matching(list(self.options_dict.keys()),key,ret_index=False)
+ ifnotmatch:
+ raiseValueError("Option not found!")
+ iflen(match)>1:
+ raiseValueError(f"Multiple matches: {', '.join(match)}. Please be more specific.")
+ match=match[0]
+ op=self.get(match,return_obj=True)
+ op.set(value,**kwargs)
+ returnop.value
+
+
[docs]defall(self,return_objs=False):
+ """
+ Get all options defined on this handler.
+
+ Args:
+ return_objs (bool, optional): Return the actual Option objects rather
+ than their values.
+ Returns:
+ all_options (dict): All options on this handler, either `{key: value}`
+ or `{key: <Option>}` if `return_objs` is `True`.
+
+ """
+ return[self.get(key,return_obj=return_objs)forkeyinself.options_dict]
+#
+# Copyright (c) 2009-2010 Gintautas Miliauskas
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+
+"""
+Pickle field implementation for Django.
+
+Modified for Evennia by Griatch and the Evennia community.
+
+"""
+fromastimportliteral_eval
+fromdatetimeimportdatetime
+
+fromcopyimportdeepcopy,ErrorasCopyError
+frombase64importb64encode,b64decode
+fromzlibimportcompress,decompress
+
+# import six # this is actually a pypy component, not in default syslib
+fromdjango.core.exceptionsimportValidationError
+fromdjango.dbimportmodels
+
+fromdjango.forms.fieldsimportCharField
+fromdjango.forms.widgetsimportTextarea
+
+frompickleimportloads,dumps
+fromdjango.utils.encodingimportforce_str
+fromevennia.utils.dbserializeimportpack_dbobj
+
+
+DEFAULT_PROTOCOL=4
+
+
+
[docs]classPickledObject(str):
+ """
+ A subclass of string so it can be told whether a string is a pickled
+ object or not (if the object is an instance of this class then it must
+ [well, should] be a pickled one).
+
+ Only really useful for passing pre-encoded values to ``default``
+ with ``dbsafe_encode``, not that doing so is necessary. If you
+ remove PickledObject and its references, you won't be able to pass
+ in pre-encoded values anymore, but you can always just pass in the
+ python objects themselves.
+ """
+
+
+class_ObjectWrapper(object):
+ """
+ A class used to wrap object that have properties that may clash with the
+ ORM internals.
+
+ For example, objects with the `prepare_database_save` property such as
+ `django.db.Model` subclasses won't work under certain conditions and the
+ same apply for trying to retrieve any `callable` object.
+ """
+
+ __slots__=("_obj",)
+
+ def__init__(self,obj):
+ self._obj=obj
+
+
+
[docs]defdbsafe_encode(value,compress_object=False,pickle_protocol=DEFAULT_PROTOCOL):
+ # We use deepcopy() here to avoid a problem with cPickle, where dumps
+ # can generate different character streams for same lookup value if
+ # they are referenced differently.
+ # The reason this is important is because we do all of our lookups as
+ # simple string matches, thus the character streams must be the same
+ # for the lookups to work properly. See tests.py for more information.
+ try:
+ value=deepcopy(value)
+ exceptCopyError:
+ # this can happen on a manager query where the search query string is a
+ # database model.
+ value=pack_dbobj(value)
+
+ value=dumps(value,protocol=pickle_protocol)
+
+ ifcompress_object:
+ value=compress(value)
+ value=b64encode(value).decode()# decode bytes to str
+ returnPickledObject(value)
[docs]classPickledWidget(Textarea):
+ """
+ This is responsible for outputting HTML representing a given field.
+ """
+
+
[docs]defrender(self,name,value,attrs=None,renderer=None):
+ """Display of the PickledField in django admin"""
+
+ repr_value=repr(value)
+
+ # analyze represented value to see how big the field should be
+ ifattrsisnotNone:
+ attrs["name"]=name
+ else:
+ attrs={"name":name}
+ attrs["cols"]=30
+ # adapt number of rows to number of lines in string
+ rows=1
+ ifisinstance(value,str)and"\n"inrepr_value:
+ rows=max(1,len(value.split("\n")))
+ attrs["rows"]=rows
+ attrs=self.build_attrs(attrs)
+
+ try:
+ # necessary to convert it back after repr(), otherwise validation errors will mutate it
+ value=literal_eval(repr_value)
+ except(ValueError,SyntaxError):
+ # we could not eval it, just show its prepresentation
+ value=repr_value
+ returnsuper().render(name,value,attrs=attrs,renderer=renderer)
[docs]classPickledFormField(CharField):
+ """
+ This represents one input field for the form.
+
+ """
+
+ widget=PickledWidget
+ default_error_messages=dict(CharField.default_error_messages)
+ default_error_messages["invalid"]=(
+ "This is not a Python Literal. You can store things like strings, "
+ "integers, or floats, but you must do it by typing them as you would "
+ "type them in the Python Interpreter. For instance, strings must be "
+ "surrounded by quote marks. We have converted it to a string for your "
+ "convenience. If it is acceptable, please hit save again."
+ )
+
+
[docs]def__init__(self,*args,**kwargs):
+ # This needs to fall through to literal_eval.
+ kwargs["required"]=False
+ super().__init__(*args,**kwargs)
+
+
[docs]defclean(self,value):
+ value=super().clean(value)
+
+ # handle empty input
+ try:
+ ifnotvalue.strip():
+ # Field was left blank. Make this None.
+ value="None"
+ exceptAttributeError:
+ pass
+
+ # parse raw Python
+ try:
+ returnliteral_eval(value)
+ except(ValueError,SyntaxError):
+ pass
+
+ # fall through to parsing the repr() of the data
+ try:
+ value=repr(value)
+ returnliteral_eval(value)
+ except(ValueError,SyntaxError):
+ raiseValidationError(self.error_messages["invalid"])
+
+
+
[docs]classPickledObjectField(models.Field):
+ """
+ A field that will accept *any* python object and store it in the
+ database. PickledObjectField will optionally compress its values if
+ declared with the keyword argument ``compress=True``.
+
+ Does not actually encode and compress ``None`` objects (although you
+ can still do lookups using None). This way, it is still possible to
+ use the ``isnull`` lookup type correctly.
+ """
+
+
[docs]defget_default(self):
+ """
+ Returns the default value for this field.
+
+ The default implementation on models.Field calls force_str
+ on the default, which means you can't set arbitrary Python
+ objects as the default. To fix this, we just return the value
+ without calling force_str on it. Note that if you set a
+ callable as a default, the field will still call it. It will
+ *not* try to pickle and encode it.
+
+ """
+ ifself.has_default():
+ ifcallable(self.default):
+ returnself.default()
+ returnself.default
+ # If the field doesn't have a default, then we punt to models.Field.
+ returnsuper().get_default()
+
+
[docs]deffrom_db_value(self,value,*args):
+ """
+ B64decode and unpickle the object, optionally decompressing it.
+
+ If an error is raised in de-pickling and we're sure the value is
+ a definite pickle, the error is allowed to propagate. If we
+ aren't sure if the value is a pickle or not, then we catch the
+ error and return the original value instead.
+
+ """
+ ifvalueisnotNone:
+ try:
+ value=dbsafe_decode(value,self.compress)
+ exceptException:
+ # If the value is a definite pickle; and an error is raised in
+ # de-pickling it should be allowed to propogate.
+ ifisinstance(value,PickledObject):
+ raise
+ else:
+ ifisinstance(value,_ObjectWrapper):
+ returnvalue._obj
+ returnvalue
[docs]defget_db_prep_value(self,value,connection=None,prepared=False):
+ """
+ Pickle and b64encode the object, optionally compressing it.
+
+ The pickling protocol is specified explicitly (by default 2),
+ rather than as -1 or HIGHEST_PROTOCOL, because we don't want the
+ protocol to change over time. If it did, ``exact`` and ``in``
+ lookups would likely fail, since pickle would now be generating
+ a different string.
+
+ """
+ ifvalueisnotNoneandnotisinstance(value,PickledObject):
+ # We call force_str here explicitly, so that the encoded string
+ # isn't rejected by the postgresql backend. Alternatively,
+ # we could have just registered PickledObject with the psycopg
+ # marshaller (telling it to store it like it would a string), but
+ # since both of these methods result in the same value being stored,
+ # doing things this way is much easier.
+ value=force_str(dbsafe_encode(value,self.compress,self.protocol))
+ returnvalue
[docs]defget_db_prep_lookup(self,lookup_type,value,connection=None,prepared=False):
+ iflookup_typenotin["exact","in","isnull"]:
+ raiseTypeError("Lookup type %s is not supported."%lookup_type)
+ # The Field model already calls get_db_prep_value before doing the
+ # actual lookup, so all we need to do is limit the lookup types.
+ returnsuper().get_db_prep_lookup(
+ lookup_type,value,connection=connection,prepared=prepared
+ )
+"""
+This is a convenient container gathering all the main
+search methods for the various database tables.
+
+It is intended to be used e.g. as
+
+> from evennia.utils import search
+> match = search.objects(...)
+
+Note that this is not intended to be a complete listing of all search
+methods! You need to refer to the respective manager to get all
+possible search methods. To get to the managers from your code, import
+the database model and call its 'objects' property.
+
+Also remember that all commands in this file return lists (also if
+there is only one match) unless noted otherwise.
+
+Example: To reach the search method 'get_object_with_account'
+ in evennia/objects/managers.py:
+
+> from evennia.objects.models import ObjectDB
+> match = Object.objects.get_object_with_account(...)
+
+
+"""
+
+# Import the manager methods to be wrapped
+
+fromdjango.db.utilsimportOperationalError
+fromdjango.contrib.contenttypes.modelsimportContentType
+
+# limit symbol import from API
+__all__=(
+ "search_object",
+ "search_account",
+ "search_script",
+ "search_message",
+ "search_channel",
+ "search_help_entry",
+ "search_tag",# object-tag
+ "search_script_tag",
+ "search_account_tag",
+ "search_channel_tag",
+)
+
+
+# import objects this way to avoid circular import problems
+try:
+ ObjectDB=ContentType.objects.get(app_label="objects",model="objectdb").model_class()
+ AccountDB=ContentType.objects.get(app_label="accounts",model="accountdb").model_class()
+ ScriptDB=ContentType.objects.get(app_label="scripts",model="scriptdb").model_class()
+ Msg=ContentType.objects.get(app_label="comms",model="msg").model_class()
+ ChannelDB=ContentType.objects.get(app_label="comms",model="channeldb").model_class()
+ HelpEntry=ContentType.objects.get(app_label="help",model="helpentry").model_class()
+ Tag=ContentType.objects.get(app_label="typeclasses",model="tag").model_class()
+exceptOperationalError:
+ # this is a fallback used during tests/doc building
+ print("Couldn't initialize search managers - db not set up.")
+ fromevennia.objects.modelsimportObjectDB
+ fromevennia.accounts.modelsimportAccountDB
+ fromevennia.scripts.modelsimportScriptDB
+ fromevennia.comms.modelsimportMsg,ChannelDB
+ fromevennia.help.modelsimportHelpEntry
+ fromevennia.typeclasses.tagsimportTag
+
+# -------------------------------------------------------------------
+# Search manager-wrappers
+# -------------------------------------------------------------------
+
+#
+# Search objects as a character
+#
+# NOTE: A more powerful wrapper of this method
+# is reachable from within each command class
+# by using self.caller.search()!
+#
+# def object_search(self, ostring=None,
+# attribute_name=None,
+# typeclass=None,
+# candidates=None,
+# exact=True):
+#
+# Search globally or in a list of candidates and return results.
+# The result is always a list of Objects (or the empty list)
+#
+# Arguments:
+# ostring: (str) The string to compare names against. By default (if
+# not attribute_name is set), this will search object.key
+# and object.aliases in order. Can also be on the form #dbref,
+# which will, if exact=True be matched against primary key.
+# attribute_name: (str): Use this named ObjectAttribute to match ostring
+# against, instead of the defaults.
+# typeclass (str or TypeClass): restrict matches to objects having
+# this typeclass. This will help speed up global searches.
+# candidates (list obj ObjectDBs): If supplied, search will only be
+# performed among the candidates in this list. A common list
+# of candidates is the contents of the current location.
+# exact (bool): Match names/aliases exactly or partially. Partial
+# matching matches the beginning of words in the names/aliases,
+# using a matching routine to separate multiple matches in
+# names with multiple components (so "bi sw" will match
+# "Big sword"). Since this is more expensive than exact
+# matching, it is recommended to be used together with
+# the objlist keyword to limit the number of possibilities.
+# This keyword has no meaning if attribute_name is set.
+#
+# Returns:
+# A list of matching objects (or a list with one unique match)
+# def object_search(self, ostring, caller=None,
+# candidates=None,
+# attribute_name=None):
+#
+search_object=ObjectDB.objects.object_search
+search_objects=search_object
+object_search=search_object
+objects=search_objects
+
+#
+# Search for accounts
+#
+# account_search(self, ostring)
+
+# Searches for a particular account by name or
+# database id.
+#
+# ostring = a string or database id.
+#
+
+search_account=AccountDB.objects.account_search
+search_accounts=search_account
+account_search=search_account
+accounts=search_accounts
+
+#
+# Searching for scripts
+#
+# script_search(self, ostring, obj=None, only_timed=False)
+#
+# Search for a particular script.
+#
+# ostring - search criterion - a script ID or key
+# obj - limit search to scripts defined on this object
+# only_timed - limit search only to scripts that run
+# on a timer.
+#
+
+search_script=ScriptDB.objects.script_search
+search_scripts=search_script
+script_search=search_script
+scripts=search_scripts
+#
+# Searching for communication messages
+#
+#
+# message_search(self, sender=None, receiver=None, channel=None, freetext=None)
+#
+# Search the message database for particular messages. At least one
+# of the arguments must be given to do a search.
+#
+# sender - get messages sent by a particular account
+# receiver - get messages received by a certain account
+# channel - get messages sent to a particular channel
+# freetext - Search for a text string in a message.
+# NOTE: This can potentially be slow, so make sure to supply
+# one of the other arguments to limit the search.
+#
+
+search_message=Msg.objects.message_search
+search_messages=search_message
+message_search=search_message
+messages=search_messages
+
+#
+# Search for Communication Channels
+#
+# channel_search(self, ostring)
+#
+# Search the channel database for a particular channel.
+#
+# ostring - the key or database id of the channel.
+# exact - requires an exact ostring match (not case sensitive)
+#
+
+search_channel=ChannelDB.objects.channel_search
+search_channels=search_channel
+channel_search=search_channel
+channels=search_channels
+
+#
+# Find help entry objects.
+#
+# search_help(self, ostring, help_category=None)
+#
+# Retrieve a search entry object.
+#
+# ostring - the help topic to look for
+# category - limit the search to a particular help topic
+#
+
+search_help=HelpEntry.objects.search_help
+search_help_entry=search_help
+search_help_entries=search_help
+help_entry_search=search_help
+help_entries=search_help
+
+
+# Locate Attributes
+
+# search_object_attribute(key, category, value, strvalue) (also search_attribute works)
+# search_account_attribute(key, category, value, strvalue) (also search_attribute works)
+# search_script_attribute(key, category, value, strvalue) (also search_attribute works)
+# search_channel_attribute(key, category, value, strvalue) (also search_attribute works)
+
+# Note that these return the object attached to the Attribute,
+# not the attribute object itself (this is usually what you want)
+
+
+defsearch_object_attribute(
+ key=None,category=None,value=None,strvalue=None,attrtype=None,**kwargs
+):
+ returnObjectDB.objects.get_by_attribute(
+ key=key,category=category,value=value,strvalue=strvalue,attrtype=attrtype,**kwargs
+ )
+
+
+defsearch_account_attribute(
+ key=None,category=None,value=None,strvalue=None,attrtype=None,**kwargs
+):
+ returnAccountDB.objects.get_by_attribute(
+ key=key,category=category,value=value,strvalue=strvalue,attrtype=attrtype,**kwargs
+ )
+
+
+defsearch_script_attribute(
+ key=None,category=None,value=None,strvalue=None,attrtype=None,**kwargs
+):
+ returnScriptDB.objects.get_by_attribute(
+ key=key,category=category,value=value,strvalue=strvalue,attrtype=attrtype,**kwargs
+ )
+
+
+defsearch_channel_attribute(
+ key=None,category=None,value=None,strvalue=None,attrtype=None,**kwargs
+):
+ returnChannelDB.objects.get_by_attribute(
+ key=key,category=category,value=value,strvalue=strvalue,attrtype=attrtype,**kwargs
+ )
+
+
+# search for attribute objects
+search_attribute_object=ObjectDB.objects.get_attribute
+
+# Locate Tags
+
+# search_object_tag(key=None, category=None) (also search_tag works)
+# search_account_tag(key=None, category=None)
+# search_script_tag(key=None, category=None)
+# search_channel_tag(key=None, category=None)
+
+# Note that this returns the object attached to the tag, not the tag
+# object itself (this is usually what you want)
+
+
+defsearch_object_by_tag(key=None,category=None,tagtype=None,**kwargs):
+ """
+ Find object based on tag or category.
+
+ Args:
+ key (str, optional): The tag key to search for.
+ category (str, optional): The category of tag
+ to search for. If not set, uncategorized
+ tags will be searched.
+ tagtype (str, optional): 'type' of Tag, by default
+ this is either `None` (a normal Tag), `alias` or
+ `permission`. This always apply to all queried tags.
+ kwargs (any): Other optional parameter that may be supported
+ by the manager method.
+
+ Returns:
+ matches (list): List of Objects with tags matching
+ the search criteria, or an empty list if no
+ matches were found.
+
+ """
+ returnObjectDB.objects.get_by_tag(key=key,category=category,tagtype=tagtype,**kwargs)
+
+
+search_tag=search_object_by_tag# this is the most common case
+
+
+
[docs]defsearch_account_tag(key=None,category=None,tagtype=None,**kwargs):
+ """
+ Find account based on tag or category.
+
+ Args:
+ key (str, optional): The tag key to search for.
+ category (str, optional): The category of tag
+ to search for. If not set, uncategorized
+ tags will be searched.
+ tagtype (str, optional): 'type' of Tag, by default
+ this is either `None` (a normal Tag), `alias` or
+ `permission`. This always apply to all queried tags.
+ kwargs (any): Other optional parameter that may be supported
+ by the manager method.
+
+ Returns:
+ matches (list): List of Accounts with tags matching
+ the search criteria, or an empty list if no
+ matches were found.
+
+ """
+ returnAccountDB.objects.get_by_tag(key=key,category=category,tagtype=tagtype,**kwargs)
+
+
+
[docs]defsearch_script_tag(key=None,category=None,tagtype=None,**kwargs):
+ """
+ Find script based on tag or category.
+
+ Args:
+ key (str, optional): The tag key to search for.
+ category (str, optional): The category of tag
+ to search for. If not set, uncategorized
+ tags will be searched.
+ tagtype (str, optional): 'type' of Tag, by default
+ this is either `None` (a normal Tag), `alias` or
+ `permission`. This always apply to all queried tags.
+ kwargs (any): Other optional parameter that may be supported
+ by the manager method.
+
+ Returns:
+ matches (list): List of Scripts with tags matching
+ the search criteria, or an empty list if no
+ matches were found.
+
+ """
+ returnScriptDB.objects.get_by_tag(key=key,category=category,tagtype=tagtype,**kwargs)
+
+
+
[docs]defsearch_channel_tag(key=None,category=None,tagtype=None,**kwargs):
+ """
+ Find channel based on tag or category.
+
+ Args:
+ key (str, optional): The tag key to search for.
+ category (str, optional): The category of tag
+ to search for. If not set, uncategorized
+ tags will be searched.
+ tagtype (str, optional): 'type' of Tag, by default
+ this is either `None` (a normal Tag), `alias` or
+ `permission`. This always apply to all queried tags.
+ kwargs (any): Other optional parameter that may be supported
+ by the manager method.
+
+ Returns:
+ matches (list): List of Channels with tags matching
+ the search criteria, or an empty list if no
+ matches were found.
+
+ """
+ returnChannelDB.objects.get_by_tag(key=key,category=category,tagtype=tagtype,**kwargs)
+
+
+# search for tag objects (not the objects they are attached to
+search_tag_object=ObjectDB.objects.get_tag
+
[docs]defunload_module(module):
+ """
+ Reset import so one can mock global constants.
+
+ Args:
+ module (module, object or str): The module will
+ be removed so it will have to be imported again. If given
+ an object, the module in which that object sits will be unloaded. A string
+ should directly give the module pathname to unload.
+
+ Example:
+ ::
+
+ # (in a test method)
+ unload_module(foo)
+ with mock.patch("foo.GLOBALTHING", "mockval"):
+ import foo
+ ... # test code using foo.GLOBALTHING, now set to 'mockval'
+
+ Notes:
+ This allows for mocking constants global to the module, since
+ otherwise those would not be mocked (since a module is only
+ loaded once).
+
+ """
+ ifisinstance(module,str):
+ modulename=module
+ elifhasattr(module,"__module__"):
+ modulename=module.__module__
+ else:
+ modulename=module.__name__
+
+ ifmodulenameinsys.modules:
+ delsys.modules[modulename]
[docs]classLocalEvenniaTest(EvenniaTest):
+ """
+ This test class is intended for inheriting in mygame tests.
+ It helps ensure your tests are run with your own objects.
+ """
+
+ account_typeclass=settings.BASE_ACCOUNT_TYPECLASS
+ object_typeclass=settings.BASE_OBJECT_TYPECLASS
+ character_typeclass=settings.BASE_CHARACTER_TYPECLASS
+ exit_typeclass=settings.BASE_EXIT_TYPECLASS
+ room_typeclass=settings.BASE_ROOM_TYPECLASS
+ script_typeclass=settings.BASE_SCRIPT_TYPECLASS
+"""
+ANSI -> html converter
+
+Credit for original idea and implementation
+goes to Muhammad Alkarouri and his
+snippet #577349 on http://code.activestate.com.
+
+(extensively modified by Griatch 2010)
+"""
+
+importre
+fromhtmlimportescapeashtml_escape
+from.ansiimport*
+
+
+# All xterm256 RGB equivalents
+
+XTERM256_FG="\033[38;5;%sm"
+XTERM256_BG="\033[48;5;%sm"
+
+
+
[docs]classTextToHTMLparser(object):
+ """
+ This class describes a parser for converting from ANSI to html.
+ """
+
+ tabstop=4
+ # mapping html color name <-> ansi code.
+ hilite=ANSI_HILITE
+ unhilite=ANSI_UNHILITE# this will be stripped - there is no css equivalent.
+ normal=ANSI_NORMAL# "
+ underline=ANSI_UNDERLINE
+ blink=ANSI_BLINK
+ inverse=ANSI_INVERSE# this will produce an outline; no obvious css equivalent?
+ colorcodes=[
+ ("color-000",unhilite+ANSI_BLACK),# pure black
+ ("color-001",unhilite+ANSI_RED),
+ ("color-002",unhilite+ANSI_GREEN),
+ ("color-003",unhilite+ANSI_YELLOW),
+ ("color-004",unhilite+ANSI_BLUE),
+ ("color-005",unhilite+ANSI_MAGENTA),
+ ("color-006",unhilite+ANSI_CYAN),
+ ("color-007",unhilite+ANSI_WHITE),# light grey
+ ("color-008",hilite+ANSI_BLACK),# dark grey
+ ("color-009",hilite+ANSI_RED),
+ ("color-010",hilite+ANSI_GREEN),
+ ("color-011",hilite+ANSI_YELLOW),
+ ("color-012",hilite+ANSI_BLUE),
+ ("color-013",hilite+ANSI_MAGENTA),
+ ("color-014",hilite+ANSI_CYAN),
+ ("color-015",hilite+ANSI_WHITE),# pure white
+ ]+[("color-%03i"%(i+16),XTERM256_FG%("%i"%(i+16)))foriinrange(240)]
+
+ colorback=[
+ ("bgcolor-000",ANSI_BACK_BLACK),# pure black
+ ("bgcolor-001",ANSI_BACK_RED),
+ ("bgcolor-002",ANSI_BACK_GREEN),
+ ("bgcolor-003",ANSI_BACK_YELLOW),
+ ("bgcolor-004",ANSI_BACK_BLUE),
+ ("bgcolor-005",ANSI_BACK_MAGENTA),
+ ("bgcolor-006",ANSI_BACK_CYAN),
+ ("bgcolor-007",ANSI_BACK_WHITE),# light grey
+ ("bgcolor-008",hilite+ANSI_BACK_BLACK),# dark grey
+ ("bgcolor-009",hilite+ANSI_BACK_RED),
+ ("bgcolor-010",hilite+ANSI_BACK_GREEN),
+ ("bgcolor-011",hilite+ANSI_BACK_YELLOW),
+ ("bgcolor-012",hilite+ANSI_BACK_BLUE),
+ ("bgcolor-013",hilite+ANSI_BACK_MAGENTA),
+ ("bgcolor-014",hilite+ANSI_BACK_CYAN),
+ ("bgcolor-015",hilite+ANSI_BACK_WHITE),# pure white
+ ]+[("bgcolor-%03i"%(i+16),XTERM256_BG%("%i"%(i+16)))foriinrange(240)]
+
+ # make sure to escape [
+ # colorcodes = [(c, code.replace("[", r"\[")) for c, code in colorcodes]
+ # colorback = [(c, code.replace("[", r"\[")) for c, code in colorback]
+ fg_colormap=dict((code,clr)forclr,codeincolorcodes)
+ bg_colormap=dict((code,clr)forclr,codeincolorback)
+
+ # create stop markers
+ fgstop="(?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m|\033\[0m|$"
+ bgstop="(?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m|\033\[0m|$"
+ bgfgstop=bgstop[:-2]+r"(\s*)"+fgstop
+
+ fgstart="((?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m)"
+ bgstart="((?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m)"
+ bgfgstart=bgstart+r"(\s*)"+"((?:\033\[1m|\033\[22m){0,1}\033\[[3-4][0-8].*?m){0,1}"
+
+ # extract color markers, tagging the start marker and the text marked
+ re_fgs=re.compile(fgstart+"(.*?)(?="+fgstop+")")
+ re_bgs=re.compile(bgstart+"(.*?)(?="+bgstop+")")
+ re_bgfg=re.compile(bgfgstart+"(.*?)(?="+bgfgstop+")")
+
+ re_normal=re.compile(normal.replace("[",r"\["))
+ re_hilite=re.compile("(?:%s)(.*)(?=%s|%s)"%(hilite.replace("[",r"\["),fgstop,bgstop))
+ re_unhilite=re.compile("(?:%s)(.*)(?=%s|%s)"%(unhilite.replace("[",r"\["),fgstop,bgstop))
+ re_uline=re.compile("(?:%s)(.*?)(?=%s|%s)"%(underline.replace("[",r"\["),fgstop,bgstop))
+ re_blink=re.compile("(?:%s)(.*?)(?=%s|%s)"%(blink.replace("[",r"\["),fgstop,bgstop))
+ re_inverse=re.compile("(?:%s)(.*?)(?=%s|%s)"%(inverse.replace("[",r"\["),fgstop,bgstop))
+ re_string=re.compile(
+ r"(?P<htmlchars>[<&>])|(?P<tab>[\t]+)|(?P<space> +)|"
+ r"(?P<spacestart>^ )|(?P<lineend>\r\n|\r|\n)",
+ re.S|re.M|re.I,
+ )
+ re_dblspace=re.compile(r" {2,}",re.M)
+ re_url=re.compile(
+ r'((?:ftp|www|https?)\W+(?:(?!\.(?:\s|$)|&\w+;)[^"\',;$*^\\(){}<>\[\]\s])+)(\.(?:\s|$)|&\w+;|)'
+ )
+ re_mxplink=re.compile(r"\|lc(.*?)\|lt(.*?)\|le",re.DOTALL)
+
+ def_sub_bgfg(self,colormatch):
+ # print("colormatch.groups()", colormatch.groups())
+ bgcode,prespace,fgcode,text,postspace=colormatch.groups()
+ ifnotfgcode:
+ ret=r"""<span class="%s">%s%s%s</span>"""%(
+ self.bg_colormap.get(bgcode,self.fg_colormap.get(bgcode,"err")),
+ prespaceand" "*len(prespace)or"",
+ postspaceand" "*len(postspace)or"",
+ text,
+ )
+ else:
+ ret=r"""<span class="%s"><span class="%s">%s%s%s</span></span>"""%(
+ self.bg_colormap.get(bgcode,self.fg_colormap.get(bgcode,"err")),
+ self.fg_colormap.get(fgcode,self.bg_colormap.get(fgcode,"err")),
+ prespaceand" "*len(prespace)or"",
+ postspaceand" "*len(postspace)or"",
+ text,
+ )
+ returnret
+
+ def_sub_fg(self,colormatch):
+ code,text=colormatch.groups()
+ returnr"""<span class="%s">%s</span>"""%(self.fg_colormap.get(code,"err"),text)
+
+ def_sub_bg(self,colormatch):
+ code,text=colormatch.groups()
+ returnr"""<span class="%s">%s</span>"""%(self.bg_colormap.get(code,"err"),text)
+
+
[docs]defre_color(self,text):
+ """
+ Replace ansi colors with html color class names. Let the
+ client choose how it will display colors, if it wishes to.
+
+ Args:
+ text (str): the string with color to replace.
+
+ Returns:
+ text (str): Re-colored text.
+
+ """
+ text=self.re_bgfg.sub(self._sub_bgfg,text)
+ text=self.re_fgs.sub(self._sub_fg,text)
+ text=self.re_bgs.sub(self._sub_bg,text)
+ text=self.re_normal.sub("",text)
+ returntext
+
+
[docs]defre_bold(self,text):
+ """
+ Clean out superfluous hilights rather than set <strong>to make
+ it match the look of telnet.
+
+ Args:
+ text (str): Text to process.
+
+ Returns:
+ text (str): Processed text.
+
+ """
+ text=self.re_hilite.sub(r"<strong>\1</strong>",text)
+ returnself.re_unhilite.sub(r"\1",text)# strip unhilite - there is no equivalent in css.
+
+
[docs]defre_underline(self,text):
+ """
+ Replace ansi underline with html underline class name.
+
+ Args:
+ text (str): Text to process.
+
+ Returns:
+ text (str): Processed text.
+
+ """
+ returnself.re_uline.sub(r'<span class="underline">\1</span>',text)
+
+
[docs]defre_blinking(self,text):
+ """
+ Replace ansi blink with custom blink css class
+
+ Args:
+ text (str): Text to process.
+
+ Returns:
+ text (str): Processed text.
+ """
+ returnself.re_blink.sub(r'<span class="blink">\1</span>',text)
+
+
[docs]defre_inversing(self,text):
+ """
+ Replace ansi inverse with custom inverse css class
+
+ Args:
+ text (str): Text to process.
+
+ Returns:
+ text (str): Processed text.
+ """
+ returnself.re_inverse.sub(r'<span class="inverse">\1</span>',text)
+
+
[docs]defremove_bells(self,text):
+ """
+ Remove ansi specials
+
+ Args:
+ text (str): Text to process.
+
+ Returns:
+ text (str): Processed text.
+
+ """
+ returntext.replace("\07","")
+
+
[docs]defremove_backspaces(self,text):
+ """
+ Removes special escape sequences
+
+ Args:
+ text (str): Text to process.
+
+ Returns:
+ text (str): Processed text.
+
+ """
+ backspace_or_eol=r"(.\010)|(\033\[K)"
+ n=1
+ whilen>0:
+ text,n=re.subn(backspace_or_eol,"",text,1)
+ returntext
+
+
[docs]defconvert_linebreaks(self,text):
+ """
+ Extra method for cleaning linebreaks
+
+ Args:
+ text (str): Text to process.
+
+ Returns:
+ text (str): Processed text.
+
+ """
+ returntext.replace("\n",r"<br>")
+
+
[docs]defconvert_urls(self,text):
+ """
+ Replace urls (http://...) by valid HTML.
+
+ Args:
+ text (str): Text to process.
+
+ Returns:
+ text (str): Processed text.
+
+ """
+ # -> added target to output prevent the web browser from attempting to
+ # change pages (and losing our webclient session).
+ returnself.re_url.sub(r'<a href="\1" target="_blank">\1</a>\2',text)
+
+
[docs]defre_double_space(self,text):
+ """
+ HTML will swallow any normal space after the first, so if any slipped
+ through we must make sure to replace them with " "
+ """
+ returnself.re_dblspace.sub(self.sub_dblspace,text)
+
+
[docs]defsub_mxp_links(self,match):
+ """
+ Helper method to be passed to re.sub,
+ replaces MXP links with HTML code.
+
+ Args:
+ match (re.Matchobject): Match for substitution.
+
+ Returns:
+ text (str): Processed text.
+
+ """
+ cmd,text=[grp.replace('"',"\\"")forgrpinmatch.groups()]
+ val=(
+ r"""<a id="mxplink" href="#" """
+ """onclick="Evennia.msg("text",["{cmd}"],{{}});"""
+ """return false;">{text}</a>""".format(cmd=cmd,text=text)
+ )
+ returnval
+
+
[docs]defsub_text(self,match):
+ """
+ Helper method to be passed to re.sub,
+ for handling all substitutions.
+
+ Args:
+ match (re.Matchobject): Match for substitution.
+
+ Returns:
+ text (str): Processed text.
+
+ """
+ cdict=match.groupdict()
+ ifcdict["htmlchars"]:
+ returnhtml_escape(cdict["htmlchars"])
+ elifcdict["lineend"]:
+ return"<br>"
+ elifcdict["tab"]:
+ text=cdict["tab"].replace("\t"," "+" "*(self.tabstop-1))
+ returntext
+ elifcdict["space"]orcdict["spacestart"]:
+ text=cdict["space"]
+ text=" "iflen(text)==1else" "+text[1:].replace(" "," ")
+ returntext
+ returnNone
+
+
[docs]defsub_dblspace(self,match):
+ "clean up double-spaces"
+ return" "+" "*(len(match.group())-1)
+
+
[docs]defparse(self,text,strip_ansi=False):
+ """
+ Main access function, converts a text containing ANSI codes
+ into html statements.
+
+ Args:
+ text (str): Text to process.
+ strip_ansi (bool, optional):
+
+ Returns:
+ text (str): Parsed text.
+ """
+ # parse everything to ansi first
+ text=parse_ansi(text,strip_ansi=strip_ansi,xterm256=True,mxp=True)
+ # convert all ansi to html
+ result=re.sub(self.re_string,self.sub_text,text)
+ result=re.sub(self.re_mxplink,self.sub_mxp_links,result)
+ result=self.re_color(result)
+ result=self.re_bold(result)
+ result=self.re_underline(result)
+ result=self.re_blinking(result)
+ result=self.re_inversing(result)
+ result=self.remove_bells(result)
+ result=self.convert_linebreaks(result)
+ result=self.remove_backspaces(result)
+ result=self.convert_urls(result)
+ result=self.re_double_space(result)
+ # clean out eventual ansi that was missed
+ # result = parse_ansi(result, strip_ansi=True)
+
+ returnresult
+# -*- encoding: utf-8 -*-
+"""
+General helper functions that don't fit neatly under any given category.
+
+They provide some useful string and conversion methods that might
+be of use when designing your own game.
+
+"""
+importos
+importgc
+importsys
+importcopy
+importtypes
+importmath
+importre
+importtextwrap
+importrandom
+importinspect
+importtraceback
+importimportlib
+importimportlib.util
+importimportlib.machinery
+fromunicodedataimporteast_asian_width
+fromtwisted.internet.taskimportdeferLater
+fromtwisted.internet.deferimportreturnValue# noqa - used as import target
+fromos.pathimportjoinasosjoin
+frominspectimportismodule,trace,getmembers,getmodule,getmro
+fromcollectionsimportdefaultdict,OrderedDict
+fromtwisted.internetimportthreads,reactor
+fromdjango.confimportsettings
+fromdjango.utilsimporttimezone
+fromdjango.utils.translationimportgettextas_
+fromdjango.appsimportapps
+fromdjango.core.validatorsimportvalidate_emailasdjango_validate_email
+fromdjango.core.exceptionsimportValidationErrorasDjangoValidationError
+fromevennia.utilsimportlogger
+
+_MULTIMATCH_TEMPLATE=settings.SEARCH_MULTIMATCH_TEMPLATE
+_EVENNIA_DIR=settings.EVENNIA_DIR
+_GAME_DIR=settings.GAME_DIR
+ENCODINGS=settings.ENCODINGS
+_GA=object.__getattribute__
+_SA=object.__setattr__
+_DA=object.__delattr__
+
+
+
[docs]defis_iter(obj):
+ """
+ Checks if an object behaves iterably.
+
+ Args:
+ obj (any): Entity to check for iterability.
+
+ Returns:
+ is_iterable (bool): If `obj` is iterable or not.
+
+ Notes:
+ Strings are *not* accepted as iterable (although they are
+ actually iterable), since string iterations are usually not
+ what we want to do with a string.
+
+ """
+ ifisinstance(obj,(str,bytes)):
+ returnFalse
+
+ try:
+ returniter(obj)andTrue
+ exceptTypeError:
+ returnFalse
+
+
+
[docs]defmake_iter(obj):
+ """
+ Makes sure that the object is always iterable.
+
+ Args:
+ obj (any): Object to make iterable.
+
+ Returns:
+ iterable (list or iterable): The same object
+ passed-through or made iterable.
+
+ """
+ returnnotis_iter(obj)and[obj]orobj
+
+
+
[docs]defwrap(text,width=None,indent=0):
+ """
+ Safely wrap text to a certain number of characters.
+
+ Args:
+ text (str): The text to wrap.
+ width (int, optional): The number of characters to wrap to.
+ indent (int): How much to indent each line (with whitespace).
+
+ Returns:
+ text (str): Properly wrapped text.
+
+ """
+ width=widthifwidthelsesettings.CLIENT_DEFAULT_WIDTH
+ ifnottext:
+ return""
+ indent=" "*indent
+ returnto_str(textwrap.fill(text,width,initial_indent=indent,subsequent_indent=indent))
+
+
+# alias - fill
+fill=wrap
+
+
+
[docs]defpad(text,width=None,align="c",fillchar=" "):
+ """
+ Pads to a given width.
+
+ Args:
+ text (str): Text to pad.
+ width (int, optional): The width to pad to, in characters.
+ align (str, optional): This is one of 'c', 'l' or 'r' (center,
+ left or right).
+ fillchar (str, optional): The character to fill with.
+
+ Returns:
+ text (str): The padded text.
+
+ """
+ width=widthifwidthelsesettings.CLIENT_DEFAULT_WIDTH
+ align=alignifalignin("c","l","r")else"c"
+ fillchar=fillchar[0]iffillcharelse" "
+ ifalign=="l":
+ returntext.ljust(width,fillchar)
+ elifalign=="r":
+ returntext.rjust(width,fillchar)
+ else:
+ returntext.center(width,fillchar)
+
+
+
[docs]defcrop(text,width=None,suffix="[...]"):
+ """
+ Crop text to a certain width, throwing away text from too-long
+ lines.
+
+ Args:
+ text (str): Text to crop.
+ width (int, optional): Width of line to crop, in characters.
+ suffix (str, optional): This is appended to the end of cropped
+ lines to show that the line actually continues. Cropping
+ will be done so that the suffix will also fit within the
+ given width. If width is too small to fit both crop and
+ suffix, the suffix will be dropped.
+
+ Returns:
+ text (str): The cropped text.
+
+ """
+ width=widthifwidthelsesettings.CLIENT_DEFAULT_WIDTH
+ ltext=len(text)
+ ifltext<=width:
+ returntext
+ else:
+ lsuffix=len(suffix)
+ text=text[:width]iflsuffix>=widthelse"%s%s"%(text[:width-lsuffix],suffix)
+ returnto_str(text)
+
+
+
[docs]defdedent(text,baseline_index=None):
+ """
+ Safely clean all whitespace at the left of a paragraph.
+
+ Args:
+ text (str): The text to dedent.
+ baseline_index (int or None, optional): Which row to use as a 'base'
+ for the indentation. Lines will be dedented to this level but
+ no further. If None, indent so as to completely deindent the
+ least indented text.
+
+ Returns:
+ text (str): Dedented string.
+
+ Notes:
+ This is useful for preserving triple-quoted string indentation
+ while still shifting it all to be next to the left edge of the
+ display.
+
+ """
+ ifnottext:
+ return""
+ ifbaseline_indexisNone:
+ returntextwrap.dedent(text)
+ else:
+ lines=text.split("\n")
+ baseline=lines[baseline_index]
+ spaceremove=len(baseline)-len(baseline.lstrip(" "))
+ return"\n".join(
+ line[min(spaceremove,len(line)-len(line.lstrip(" "))):]forlineinlines
+ )
+
+
+
[docs]defjustify(text,width=None,align="f",indent=0):
+ """
+ Fully justify a text so that it fits inside `width`. When using
+ full justification (default) this will be done by padding between
+ words with extra whitespace where necessary. Paragraphs will
+ be retained.
+
+ Args:
+ text (str): Text to justify.
+ width (int, optional): The length of each line, in characters.
+ align (str, optional): The alignment, 'l', 'c', 'r' or 'f'
+ for left, center, right or full justification respectively.
+ indent (int, optional): Number of characters indentation of
+ entire justified text block.
+
+ Returns:
+ justified (str): The justified and indented block of text.
+
+ """
+ width=widthifwidthelsesettings.CLIENT_DEFAULT_WIDTH
+
+ def_process_line(line):
+ """
+ helper function that distributes extra spaces between words. The number
+ of gaps is nwords - 1 but must be at least 1 for single-word lines. We
+ distribute odd spaces randomly to one of the gaps.
+ """
+ line_rest=width-(wlen+ngaps)
+ gap=" "# minimum gap between words
+ ifline_rest>0:
+ ifalign=="l":
+ ifline[-1]=="\n\n":
+ line[-1]=" "*(line_rest-1)+"\n"+" "*width+"\n"+" "*width
+ else:
+ line[-1]+=" "*line_rest
+ elifalign=="r":
+ line[0]=" "*line_rest+line[0]
+ elifalign=="c":
+ pad=" "*(line_rest//2)
+ line[0]=pad+line[0]
+ ifline[-1]=="\n\n":
+ line[-1]+=(
+ pad+" "*(line_rest%2-1)+"\n"+" "*width+"\n"+" "*width
+ )
+ else:
+ line[-1]=line[-1]+pad+" "*(line_rest%2)
+ else:# align 'f'
+ gap+=" "*(line_rest//max(1,ngaps))
+ rest_gap=line_rest%max(1,ngaps)
+ foriinrange(rest_gap):
+ line[i]+=" "
+ elifnotany(line):
+ return[" "*width]
+ returngap.join(line)
+
+ # split into paragraphs and words
+ paragraphs=re.split("\n\s*?\n",text,re.MULTILINE)
+ words=[]
+ forip,paragraphinenumerate(paragraphs):
+ ifip>0:
+ words.append(("\n",0))
+ words.extend((word,len(word))forwordinparagraph.split())
+ ngaps,wlen,line=0,0,[]
+
+ lines=[]
+ whilewords:
+ ifnotline:
+ # start a new line
+ word=words.pop(0)
+ wlen=word[1]
+ line.append(word[0])
+ elif(words[0][1]+wlen+ngaps)>=width:
+ # next word would exceed word length of line + smallest gaps
+ lines.append(_process_line(line))
+ ngaps,wlen,line=0,0,[]
+ else:
+ # put a new word on the line
+ word=words.pop(0)
+ line.append(word[0])
+ ifword[1]==0:
+ # a new paragraph, process immediately
+ lines.append(_process_line(line))
+ ngaps,wlen,line=0,0,[]
+ else:
+ wlen+=word[1]
+ ngaps+=1
+
+ ifline:# catch any line left behind
+ lines.append(_process_line(line))
+ indentstring=" "*indent
+ return"\n".join([indentstring+lineforlineinlines])
+
+
+
[docs]defcolumnize(string,columns=2,spacing=4,align="l",width=None):
+ """
+ Break a string into a number of columns, using as little
+ vertical space as possible.
+
+ Args:
+ string (str): The string to columnize.
+ columns (int, optional): The number of columns to use.
+ spacing (int, optional): How much space to have between columns.
+ width (int, optional): The max width of the columns.
+ Defaults to client's default width.
+
+ Returns:
+ columns (str): Text divided into columns.
+
+ Raises:
+ RuntimeError: If given invalid values.
+
+ """
+ columns=max(1,columns)
+ spacing=max(1,spacing)
+ width=widthifwidthelsesettings.CLIENT_DEFAULT_WIDTH
+
+ w_spaces=(columns-1)*spacing
+ w_txt=max(1,width-w_spaces)
+
+ ifw_spaces+columns>width:# require at least 1 char per column
+ raiseRuntimeError("Width too small to fit columns")
+
+ colwidth=int(w_txt/(1.0*columns))
+
+ # first make a single column which we then split
+ onecol=justify(string,width=colwidth,align=align)
+ onecol=onecol.split("\n")
+
+ nrows,dangling=divmod(len(onecol),columns)
+ nrows=[nrows+1ifi<danglingelsenrowsforiinrange(columns)]
+
+ height=max(nrows)
+ cols=[]
+ istart=0
+ forirowsinnrows:
+ cols.append(onecol[istart:istart+irows])
+ istart=istart+irows
+ forcolincols:
+ iflen(col)<height:
+ col.append(" "*colwidth)
+
+ sep=" "*spacing
+ rows=[]
+ forirowinrange(height):
+ rows.append(sep.join(col[irow]forcolincols))
+
+ return"\n".join(rows)
+
+
+
[docs]defiter_to_string(initer,endsep="and",addquote=False):
+ """
+ This pretty-formats an iterable list as string output, adding an optional
+ alternative separator to the second to last entry. If `addquote`
+ is `True`, the outgoing strings will be surrounded by quotes.
+
+ Args:
+ initer (any): Usually an iterable to print. Each element must be possible to
+ present with a string. Note that if this is a generator, it will be
+ consumed by this operation.
+ endsep (str, optional): If set, the last item separator will
+ be replaced with this value.
+ addquote (bool, optional): This will surround all outgoing
+ values with double quotes.
+
+ Returns:
+ liststr (str): The list represented as a string.
+
+ Examples:
+
+ ```python
+ # no endsep:
+ [1,2,3] -> '1, 2, 3'
+ # with endsep=='and':
+ [1,2,3] -> '1, 2 and 3'
+ # with addquote and endsep
+ [1,2,3] -> '"1", "2" and "3"'
+ ```
+
+ """
+ ifnotendsep:
+ endsep=","
+ else:
+ endsep=" "+endsep
+ ifnotiniter:
+ return""
+ initer=tuple(str(val)forvalinmake_iter(initer))
+ ifaddquote:
+ iflen(initer)==1:
+ return'"%s"'%initer[0]
+ return", ".join('"%s"'%vforvininiter[:-1])+"%s%s"%(endsep,'"%s"'%initer[-1])
+ else:
+ iflen(initer)==1:
+ returnstr(initer[0])
+ return", ".join(str(v)forvininiter[:-1])+"%s%s"%(endsep,initer[-1])
+
+
+# legacy alias
+list_to_string=iter_to_string
+
+
+
[docs]defwildcard_to_regexp(instring):
+ """
+ Converts a player-supplied string that may have wildcards in it to
+ regular expressions. This is useful for name matching.
+
+ Args:
+ instring (string): A string that may potentially contain
+ wildcards (`*` or `?`).
+
+ Returns:
+ regex (str): A string where wildcards were replaced with
+ regular expressions.
+
+ """
+ regexp_string=""
+
+ # If the string starts with an asterisk, we can't impose the beginning of
+ # string (^) limiter.
+ ifinstring[0]!="*":
+ regexp_string+="^"
+
+ # Replace any occurances of * or ? with the appropriate groups.
+ regexp_string+=instring.replace("*","(.*)").replace("?","(.{1})")
+
+ # If there's an asterisk at the end of the string, we can't impose the
+ # end of string ($) limiter.
+ ifinstring[-1]!="*":
+ regexp_string+="$"
+
+ returnregexp_string
+
+
+
[docs]deftime_format(seconds,style=0):
+ """
+ Function to return a 'prettified' version of a value in seconds.
+
+ Args:
+ seconds (int): Number if seconds to format.
+ style (int): One of the following styles:
+ 0. "1d 08:30"
+ 1. "1d"
+ 2. "1 day, 8 hours, 30 minutes"
+ 3. "1 day, 8 hours, 30 minutes, 10 seconds"
+ 4. highest unit (like "3 years" or "8 months" or "1 second")
+ Returns:
+ timeformatted (str): A pretty time string.
+ """
+ ifseconds<0:
+ seconds=0
+ else:
+ # We'll just use integer math, no need for decimal precision.
+ seconds=int(seconds)
+
+ days=seconds//86400
+ seconds-=days*86400
+ hours=seconds//3600
+ seconds-=hours*3600
+ minutes=seconds//60
+ seconds-=minutes*60
+
+ retval=""
+ ifstyle==0:
+ """
+ Standard colon-style output.
+ """
+ ifdays>0:
+ retval="%id %02i:%02i"%(days,hours,minutes)
+ else:
+ retval="%02i:%02i"%(hours,minutes)
+ returnretval
+
+ elifstyle==1:
+ """
+ Simple, abbreviated form that only shows the highest time amount.
+ """
+ ifdays>0:
+ return"%id"%(days,)
+ elifhours>0:
+ return"%ih"%(hours,)
+ elifminutes>0:
+ return"%im"%(minutes,)
+ else:
+ return"%is"%(seconds,)
+ elifstyle==2:
+ """
+ Full-detailed, long-winded format. We ignore seconds.
+ """
+ days_str=hours_str=""
+ minutes_str="0 minutes"
+
+ ifdays>0:
+ ifdays==1:
+ days_str="%i day, "%days
+ else:
+ days_str="%i days, "%days
+ ifdaysorhours>0:
+ ifhours==1:
+ hours_str="%i hour, "%hours
+ else:
+ hours_str="%i hours, "%hours
+ ifhoursorminutes>0:
+ ifminutes==1:
+ minutes_str="%i minute "%minutes
+ else:
+ minutes_str="%i minutes "%minutes
+ retval="%s%s%s"%(days_str,hours_str,minutes_str)
+ elifstyle==3:
+ """
+ Full-detailed, long-winded format. Includes seconds.
+ """
+ days_str=hours_str=minutes_str=seconds_str=""
+ ifdays>0:
+ ifdays==1:
+ days_str="%i day, "%days
+ else:
+ days_str="%i days, "%days
+ ifdaysorhours>0:
+ ifhours==1:
+ hours_str="%i hour, "%hours
+ else:
+ hours_str="%i hours, "%hours
+ ifhoursorminutes>0:
+ ifminutes==1:
+ minutes_str="%i minute "%minutes
+ else:
+ minutes_str="%i minutes "%minutes
+ ifminutesorseconds>0:
+ ifseconds==1:
+ seconds_str="%i second "%seconds
+ else:
+ seconds_str="%i seconds "%seconds
+ retval="%s%s%s%s"%(days_str,hours_str,minutes_str,seconds_str)
+ elifstyle==4:
+ """
+ Only return the highest unit.
+ """
+ ifdays>=730:# Several years
+ return"{} years".format(days//365)
+ elifdays>=365:# One year
+ return"a year"
+ elifdays>=62:# Several months
+ return"{} months".format(days//31)
+ elifdays>=31:# One month
+ return"a month"
+ elifdays>=2:# Several days
+ return"{} days".format(days)
+ elifdays>0:
+ return"a day"
+ elifhours>=2:# Several hours
+ return"{} hours".format(hours)
+ elifhours>0:# One hour
+ return"an hour"
+ elifminutes>=2:# Several minutes
+ return"{} minutes".format(minutes)
+ elifminutes>0:# One minute
+ return"a minute"
+ elifseconds>=2:# Several seconds
+ return"{} seconds".format(seconds)
+ elifseconds==1:
+ return"a second"
+ else:
+ return"0 seconds"
+ else:
+ raiseValueError("Unknown style for time format: %s"%style)
+
+ returnretval.strip()
+
+
+
[docs]defdatetime_format(dtobj):
+ """
+ Pretty-prints the time since a given time.
+
+ Args:
+ dtobj (datetime): An datetime object, e.g. from Django's
+ `DateTimeField`.
+
+ Returns:
+ deltatime (str): A string describing how long ago `dtobj`
+ took place.
+
+ """
+
+ now=timezone.now()
+
+ ifdtobj.year<now.year:
+ # another year (Apr 5, 2019)
+ timestring=dtobj.strftime(f"%b {dtobj.day}, %Y")
+ elifdtobj.date()<now.date():
+ # another date, same year (Apr 5)
+ timestring=dtobj.strftime(f"%b {dtobj.day}")
+ elifdtobj.hour<now.hour-1:
+ # same day, more than 1 hour ago (10:45)
+ timestring=dtobj.strftime("%H:%M")
+ else:
+ # same day, less than 1 hour ago (10:45:33)
+ timestring=dtobj.strftime("%H:%M:%S")
+ returntimestring
+
+
+
[docs]defhost_os_is(osname):
+ """
+ Check to see if the host OS matches the query.
+
+ Args:
+ osname (str): Common names are "posix" (linux/unix/mac) and
+ "nt" (windows).
+
+ Args:
+ is_os (bool): If the os matches or not.
+
+ """
+ returnos.name==osname
+
+
+
[docs]defget_evennia_version(mode="long"):
+ """
+ Helper method for getting the current evennia version.
+
+ Args:
+ mode (str, optional): One of:
+ - long: 0.9.0 rev342453534
+ - short: 0.9.0
+ - pretty: Evennia 0.9.0
+
+ Returns:
+ version (str): The version string.
+
+ """
+ importevennia
+
+ vers=evennia.__version__
+ ifmode=="short":
+ returnvers.split()[0].strip()
+ elifmode=="pretty":
+ vers=vers.split()[0].strip()
+ returnf"Evennia {vers}"
+ else:# mode "long":
+ returnvers
+
+
+
[docs]defpypath_to_realpath(python_path,file_ending=".py",pypath_prefixes=None):
+ """
+ Converts a dotted Python path to an absolute path under the
+ Evennia library directory or under the current game directory.
+
+ Args:
+ python_path (str): A dot-python path
+ file_ending (str): A file ending, including the period.
+ pypath_prefixes (list): A list of paths to test for existence. These
+ should be on python.path form. EVENNIA_DIR and GAME_DIR are automatically
+ checked, they need not be added to this list.
+
+ Returns:
+ abspaths (list): All existing, absolute paths created by
+ converting `python_path` to an absolute paths and/or
+ prepending `python_path` by `settings.EVENNIA_DIR`,
+ `settings.GAME_DIR` and by`pypath_prefixes` respectively.
+
+ Notes:
+ This will also try a few combinations of paths to allow cases
+ where pypath is given including the "evennia." or "mygame."
+ prefixes.
+
+ """
+ path=python_path.strip().split(".")
+ plong=osjoin(*path)+file_ending
+ pshort=(
+ osjoin(*path[1:])+file_endingiflen(path)>1elseplong
+ )# in case we had evennia. or mygame.
+ prefixlong=(
+ [osjoin(*ppath.strip().split("."))forppathinmake_iter(pypath_prefixes)]
+ ifpypath_prefixes
+ else[]
+ )
+ prefixshort=(
+ [
+ osjoin(*ppath.strip().split(".")[1:])
+ forppathinmake_iter(pypath_prefixes)
+ iflen(ppath.strip().split("."))>1
+ ]
+ ifpypath_prefixes
+ else[]
+ )
+ paths=(
+ [plong]
+ +[osjoin(_EVENNIA_DIR,prefix,plong)forprefixinprefixlong]
+ +[osjoin(_GAME_DIR,prefix,plong)forprefixinprefixlong]
+ +[osjoin(_EVENNIA_DIR,prefix,plong)forprefixinprefixshort]
+ +[osjoin(_GAME_DIR,prefix,plong)forprefixinprefixshort]
+ +[osjoin(_EVENNIA_DIR,plong),osjoin(_GAME_DIR,plong)]
+ +[osjoin(_EVENNIA_DIR,prefix,pshort)forprefixinprefixshort]
+ +[osjoin(_GAME_DIR,prefix,pshort)forprefixinprefixshort]
+ +[osjoin(_EVENNIA_DIR,prefix,pshort)forprefixinprefixlong]
+ +[osjoin(_GAME_DIR,prefix,pshort)forprefixinprefixlong]
+ +[osjoin(_EVENNIA_DIR,pshort),osjoin(_GAME_DIR,pshort)]
+ )
+ # filter out non-existing paths
+ returnlist(set(pforpinpathsifos.path.isfile(p)))
+
+
+
[docs]defdbref(inp,reqhash=True):
+ """
+ Converts/checks if input is a valid dbref.
+
+ Args:
+ inp (int, str): A database ref on the form N or #N.
+ reqhash (bool, optional): Require the #N form to accept
+ input as a valid dbref.
+
+ Returns:
+ dbref (int or None): The integer part of the dbref or `None`
+ if input was not a valid dbref.
+
+ """
+ ifreqhash:
+ num=(
+ int(inp.lstrip("#"))
+ if(isinstance(inp,str)andinp.startswith("#")andinp.lstrip("#").isdigit())
+ elseNone
+ )
+ returnnumifisinstance(num,int)andnum>0elseNone
+ elifisinstance(inp,str):
+ inp=inp.lstrip("#")
+ returnint(inp)ifinp.isdigit()andint(inp)>0elseNone
+ else:
+ returninpifisinstance(inp,int)elseNone
+
+
+
[docs]defdbref_to_obj(inp,objclass,raise_errors=True):
+ """
+ Convert a #dbref to a valid object.
+
+ Args:
+ inp (str or int): A valid #dbref.
+ objclass (class): A valid django model to filter against.
+ raise_errors (bool, optional): Whether to raise errors
+ or return `None` on errors.
+
+ Returns:
+ obj (Object or None): An entity loaded from the dbref.
+
+ Raises:
+ Exception: If `raise_errors` is `True` and
+ `objclass.objects.get(id=dbref)` did not return a valid
+ object.
+
+ """
+ dbid=dbref(inp)
+ ifnotdbid:
+ # we only convert #dbrefs
+ returninp
+ try:
+ ifdbid<0:
+ returnNone
+ exceptValueError:
+ returnNone
+
+ # if we get to this point, inp is an integer dbref; get the matching object
+ try:
+ returnobjclass.objects.get(id=dbid)
+ exceptException:
+ ifraise_errors:
+ raise
+ returninp
+
+
+# legacy alias
+dbid_to_obj=dbref_to_obj
+
+
+# some direct translations for the latinify
+_UNICODE_MAP={
+ "EM DASH":"-",
+ "FIGURE DASH":"-",
+ "EN DASH":"-",
+ "HORIZONTAL BAR":"-",
+ "HORIZONTAL ELLIPSIS":"...",
+ "LEFT SINGLE QUOTATION MARK":"'",
+ "RIGHT SINGLE QUOTATION MARK":"'",
+ "LEFT DOUBLE QUOTATION MARK":'"',
+ "RIGHT DOUBLE QUOTATION MARK":'"',
+}
+
+
+
[docs]deflatinify(string,default="?",pure_ascii=False):
+ """
+ Convert a unicode string to "safe" ascii/latin-1 characters.
+ This is used as a last resort when normal encoding does not work.
+
+ Arguments:
+ string (str): A string to convert to 'safe characters' convertable
+ to an latin-1 bytestring later.
+ default (str, optional): Characters resisting mapping will be replaced
+ with this character or string. The intent is to apply an encode operation
+ on the string soon after.
+
+ Returns:
+ string (str): A 'latinified' string where each unicode character has been
+ replaced with a 'safe' equivalent available in the ascii/latin-1 charset.
+ Notes:
+ This is inspired by the gist by Ricardo Murri:
+ https://gist.github.com/riccardomurri/3c3ccec30f037be174d3
+
+ """
+
+ fromunicodedataimportname
+
+ ifisinstance(string,bytes):
+ string=string.decode("utf8")
+
+ converted=[]
+ forunichiniter(string):
+ try:
+ ch=unich.encode("utf8").decode("ascii")
+ exceptUnicodeDecodeError:
+ # deduce a latin letter equivalent from the Unicode data
+ # point name; e.g., since `name(u'á') == 'LATIN SMALL
+ # LETTER A WITH ACUTE'` translate `á` to `a`. However, in
+ # some cases the unicode name is still "LATIN LETTER"
+ # although no direct equivalent in the Latin alphabet
+ # exists (e.g., Þ, "LATIN CAPITAL LETTER THORN") -- we can
+ # avoid these cases by checking that the letter name is
+ # composed of one letter only.
+ # We also supply some direct-translations for some particular
+ # common cases.
+ what=name(unich)
+ ifwhatin_UNICODE_MAP:
+ ch=_UNICODE_MAP[what]
+ else:
+ what=what.split()
+ ifwhat[0]=="LATIN"andwhat[2]=="LETTER"andlen(what[3])==1:
+ ch=what[3].lower()ifwhat[1]=="SMALL"elsewhat[3].upper()
+ else:
+ ch=default
+ converted.append(chr(ord(ch)))
+ return"".join(converted)
+
+
+
[docs]defto_bytes(text,session=None):
+ """
+ Try to encode the given text to bytes, using encodings from settings or from Session. Will
+ always return a bytes, even if given something that is not str or bytes.
+
+ Args:
+ text (any): The text to encode to bytes. If bytes, return unchanged. If not a str, convert
+ to str before converting.
+ session (Session, optional): A Session to get encoding info from. Will try this before
+ falling back to settings.ENCODINGS.
+
+ Returns:
+ encoded_text (bytes): the encoded text following the session's protocol flag followed by the
+ encodings specified in settings.ENCODINGS. If all attempt fail, log the error and send
+ the text with "?" in place of problematic characters. If the specified encoding cannot
+ be found, the protocol flag is reset to utf-8. In any case, returns bytes.
+
+ Note:
+ If `text` is already bytes, return it as is.
+
+ """
+ ifisinstance(text,bytes):
+ returntext
+ ifnotisinstance(text,str):
+ # convert to a str representation before encoding
+ try:
+ text=str(text)
+ exceptException:
+ text=repr(text)
+
+ default_encoding=session.protocol_flags.get("ENCODING","utf-8")ifsessionelse"utf-8"
+ try:
+ returntext.encode(default_encoding)
+ except(LookupError,UnicodeEncodeError):
+ forencodinginsettings.ENCODINGS:
+ try:
+ returntext.encode(encoding)
+ except(LookupError,UnicodeEncodeError):
+ pass
+ # no valid encoding found. Replace unconvertable parts with ?
+ returntext.encode(default_encoding,errors="replace")
+
+
+
[docs]defto_str(text,session=None):
+ """
+ Try to decode a bytestream to a python str, using encoding schemas from settings
+ or from Session. Will always return a str(), also if not given a str/bytes.
+
+ Args:
+ text (any): The text to encode to bytes. If a str, return it. If also not bytes, convert
+ to str using str() or repr() as a fallback.
+ session (Session, optional): A Session to get encoding info from. Will try this before
+ falling back to settings.ENCODINGS.
+
+ Returns:
+ decoded_text (str): The decoded text.
+
+ Note:
+ If `text` is already str, return it as is.
+ """
+ ifisinstance(text,str):
+ returntext
+ ifnotisinstance(text,bytes):
+ # not a byte, convert directly to str
+ try:
+ returnstr(text)
+ exceptException:
+ returnrepr(text)
+
+ default_encoding=session.protocol_flags.get("ENCODING","utf-8")ifsessionelse"utf-8"
+ try:
+ returntext.decode(default_encoding)
+ except(LookupError,UnicodeDecodeError):
+ forencodinginsettings.ENCODINGS:
+ try:
+ returntext.decode(encoding)
+ except(LookupError,UnicodeDecodeError):
+ pass
+ # no valid encoding found. Replace unconvertable parts with ?
+ returntext.decode(default_encoding,errors="replace")
+
+
+
[docs]defvalidate_email_address(emailaddress):
+ """
+ Checks if an email address is syntactically correct. Makes use
+ of the django email-validator for consistency.
+
+ Args:
+ emailaddress (str): Email address to validate.
+
+ Returns:
+ bool: If this is a valid email or not.
+
+ """
+ try:
+ django_validate_email(str(emailaddress))
+ exceptDjangoValidationError:
+ returnFalse
+ exceptException:
+ logger.log_trace()
+ returnFalse
+ else:
+ returnTrue
+
+
+
[docs]definherits_from(obj,parent):
+ """
+ Takes an object and tries to determine if it inherits at *any*
+ distance from parent.
+
+ Args:
+ obj (any): Object to analyze. This may be either an instance
+ or a class.
+ parent (any): Can be either instance, class or python path to class.
+
+ Returns:
+ inherits_from (bool): If `parent` is a parent to `obj` or not.
+
+ Notes:
+ What differs this function from e.g. `isinstance()` is that `obj`
+ may be both an instance and a class, and parent may be an
+ instance, a class, or the python path to a class (counting from
+ the evennia root directory).
+
+ """
+
+ ifcallable(obj):
+ # this is a class
+ obj_paths=["%s.%s"%(mod.__module__,mod.__name__)formodinobj.mro()]
+ else:
+ obj_paths=["%s.%s"%(mod.__module__,mod.__name__)formodinobj.__class__.mro()]
+
+ ifisinstance(parent,str):
+ # a given string path, for direct matching
+ parent_path=parent
+ elifcallable(parent):
+ # this is a class
+ parent_path="%s.%s"%(parent.__module__,parent.__name__)
+ else:
+ parent_path="%s.%s"%(parent.__class__.__module__,parent.__class__.__name__)
+ returnany(1forobj_pathinobj_pathsifobj_path==parent_path)
+
+
+
[docs]defserver_services():
+ """
+ Lists all services active on the Server. Observe that since
+ services are launched in memory, this function will only return
+ any results if called from inside the game.
+
+ Returns:
+ services (dict): A dict of available services.
+
+ """
+ fromevennia.server.sessionhandlerimportSESSIONS
+
+ ifhasattr(SESSIONS,"server")andhasattr(SESSIONS.server,"services"):
+ server=SESSIONS.server.services.namedServices
+ else:
+ # This function must be called from inside the evennia process.
+ server={}
+ delSESSIONS
+ returnserver
+
+
+
[docs]defuses_database(name="sqlite3"):
+ """
+ Checks if the game is currently using a given database. This is a
+ shortcut to having to use the full backend name.
+
+ Args:
+ name (str): One of 'sqlite3', 'mysql', 'postgresql'
+ or 'oracle'.
+
+ Returns:
+ uses (bool): If the given database is used or not.
+
+ """
+ try:
+ engine=settings.DATABASES["default"]["ENGINE"]
+ exceptKeyError:
+ engine=settings.DATABASE_ENGINE
+ returnengine=="django.db.backends.%s"%name
+
+
+_TASK_HANDLER=None
+
+
+
[docs]defdelay(timedelay,callback,*args,**kwargs):
+ """
+ Delay the calling of a callback (function).
+
+ Args:
+ timedelay (int or float): The delay in seconds.
+ callback (callable): Will be called as `callback(*args, **kwargs)`
+ after `timedelay` seconds.
+ args (any): Will be used as arguments to callback.
+
+ Keyword Args:
+ persistent (bool, optional): If True the delay remains after a server restart.
+ persistent is False by default.
+ any (any): Will be used as keyword arguments to callback.
+
+ Returns:
+ task (TaskHandlerTask): An instance of a task.
+ Refer to, evennia.scripts.taskhandler.TaskHandlerTask
+
+ Note:
+ The task handler (`evennia.scripts.taskhandler.TASK_HANDLER`) will
+ be called for persistent or non-persistent tasks.
+ If persistent is set to True, the callback, its arguments
+ and other keyword arguments will be saved (serialized) in the database,
+ assuming they can be. The callback will be executed even after
+ a server restart/reload, taking into account the specified delay
+ (and server down time).
+ Keep in mind that persistent tasks arguments and callback should not
+ use memory references.
+ If persistent is set to True the delay function will return an int
+ which is the task's id itended for use with TASK_HANDLER's do_task
+ and remove methods.
+ All persistent tasks whose time delays have passed will be called on server startup.
+
+ """
+ global_TASK_HANDLER
+ # Do some imports here to avoid circular import and speed things up
+ if_TASK_HANDLERisNone:
+ fromevennia.scripts.taskhandlerimportTASK_HANDLERas_TASK_HANDLER
+ return_TASK_HANDLER.add(timedelay,callback,*args,**kwargs)
+
+
+_PPOOL=None
+_PCMD=None
+_PROC_ERR="A process has ended with a probable error condition: process ended by signal 9."
+
+
+
[docs]defrun_async(to_execute,*args,**kwargs):
+ """
+ Runs a function or executes a code snippet asynchronously.
+
+ Args:
+ to_execute (callable): If this is a callable, it will be
+ executed with `*args` and non-reserved `**kwargs` as arguments.
+ The callable will be executed using ProcPool, or in a thread
+ if ProcPool is not available.
+
+ Keyword Args:
+ at_return (callable): Should point to a callable with one
+ argument. It will be called with the return value from
+ to_execute.
+ at_return_kwargs (dict): This dictionary will be used as
+ keyword arguments to the at_return callback.
+ at_err (callable): This will be called with a Failure instance
+ if there is an error in to_execute.
+ at_err_kwargs (dict): This dictionary will be used as keyword
+ arguments to the at_err errback.
+
+ Notes:
+ All other `*args` and `**kwargs` will be passed on to
+ `to_execute`. Run_async will relay executed code to a thread
+ or procpool.
+
+ Use this function with restrain and only for features/commands
+ that you know has no influence on the cause-and-effect order of your
+ game (commands given after the async function might be executed before
+ it has finished). Accessing the same property from different threads
+ can lead to unpredicted behaviour if you are not careful (this is called a
+ "race condition").
+
+ Also note that some databases, notably sqlite3, don't support access from
+ multiple threads simultaneously, so if you do heavy database access from
+ your `to_execute` under sqlite3 you will probably run very slow or even get
+ tracebacks.
+
+ """
+
+ # handle special reserved input kwargs
+ callback=kwargs.pop("at_return",None)
+ errback=kwargs.pop("at_err",None)
+ callback_kwargs=kwargs.pop("at_return_kwargs",{})
+ errback_kwargs=kwargs.pop("at_err_kwargs",{})
+
+ ifcallable(to_execute):
+ # no process pool available, fall back to old deferToThread mechanism.
+ deferred=threads.deferToThread(to_execute,*args,**kwargs)
+ else:
+ # no appropriate input for this server setup
+ raiseRuntimeError("'%s' could not be handled by run_async"%to_execute)
+
+ # attach callbacks
+ ifcallback:
+ deferred.addCallback(callback,**callback_kwargs)
+ deferred.addErrback(errback,**errback_kwargs)
+
+
+
[docs]defcheck_evennia_dependencies():
+ """
+ Checks the versions of Evennia's dependencies including making
+ some checks for runtime libraries.
+
+ Returns:
+ result (bool): `False` if a show-stopping version mismatch is
+ found.
+
+ """
+
+ # check main dependencies
+ fromevennia.server.evennia_launcherimportcheck_main_evennia_dependencies
+
+ not_error=check_main_evennia_dependencies()
+
+ errstring=""
+ # South is no longer used ...
+ if"south"insettings.INSTALLED_APPS:
+ errstring+=(
+ "\n ERROR: 'south' found in settings.INSTALLED_APPS. "
+ "\n South is no longer used. If this was added manually, remove it."
+ )
+ not_error=False
+ # IRC support
+ ifsettings.IRC_ENABLED:
+ try:
+ importtwisted.words
+
+ twisted.words# set to avoid debug info about not-used import
+ exceptImportError:
+ errstring+=(
+ "\n ERROR: IRC is enabled, but twisted.words is not installed. Please install it."
+ "\n Linux Debian/Ubuntu users should install package 'python-twisted-words', others"
+ "\n can get it from http://twistedmatrix.com/trac/wiki/TwistedWords."
+ )
+ not_error=False
+ errstring=errstring.strip()
+ iferrstring:
+ mlen=max(len(line)forlineinerrstring.split("\n"))
+ logger.log_err("%s\n%s\n%s"%("-"*mlen,errstring,"-"*mlen))
+ returnnot_error
+
+
+
[docs]defhas_parent(basepath,obj):
+ """
+ Checks if `basepath` is somewhere in `obj`'s parent tree.
+
+ Args:
+ basepath (str): Python dotpath to compare against obj path.
+ obj (any): Object whose path is to be checked.
+
+ Returns:
+ has_parent (bool): If the check was successful or not.
+
+ """
+ try:
+ returnany(
+ cls
+ forclsinobj.__class__.mro()
+ ifbasepath=="%s.%s"%(cls.__module__,cls.__name__)
+ )
+ except(TypeError,AttributeError):
+ # this can occur if we tried to store a class object, not an
+ # instance. Not sure if one should defend against this.
+ returnFalse
+
+
+
[docs]defmod_import_from_path(path):
+ """
+ Load a Python module at the specified path.
+
+ Args:
+ path (str): An absolute path to a Python module to load.
+
+ Returns:
+ (module or None): An imported module if the path was a valid
+ Python module. Returns `None` if the import failed.
+
+ """
+ ifnotos.path.isabs(path):
+ path=os.path.abspath(path)
+ dirpath,filename=path.rsplit(os.path.sep,1)
+ modname=filename.rstrip(".py")
+
+ try:
+ returnimportlib.machinery.SourceFileLoader(modname,path).load_module()
+ exceptOSError:
+ logger.log_trace(f"Could not find module '{modname}' ({modname}.py) at path '{dirpath}'")
+ returnNone
+
+
+
[docs]defmod_import(module):
+ """
+ A generic Python module loader.
+
+ Args:
+ module (str, module): This can be either a Python path
+ (dot-notation like `evennia.objects.models`), an absolute path
+ (e.g. `/home/eve/evennia/evennia/objects/models.py`) or an
+ already imported module object (e.g. `models`)
+ Returns:
+ (module or None): An imported module. If the input argument was
+ already a module, this is returned as-is, otherwise the path is
+ parsed and imported. Returns `None` and logs error if import failed.
+
+ """
+ ifnotmodule:
+ returnNone
+
+ ifisinstance(module,types.ModuleType):
+ # if this is already a module, we are done
+ returnmodule
+
+ ifmodule.endswith(".py")andos.path.exists(module):
+ returnmod_import_from_path(module)
+
+ try:
+ returnimportlib.import_module(module)
+ exceptImportError:
+ returnNone
+
+
+
[docs]defall_from_module(module):
+ """
+ Return all global-level variables defined in a module.
+
+ Args:
+ module (str, module): This can be either a Python path
+ (dot-notation like `evennia.objects.models`), an absolute path
+ (e.g. `/home/eve/evennia/evennia/objects.models.py`) or an
+ already imported module object (e.g. `models`)
+
+ Returns:
+ variables (dict): A dict of {variablename: variable} for all
+ variables in the given module.
+
+ Notes:
+ Ignores modules and variable names starting with an underscore.
+
+ """
+ mod=mod_import(module)
+ ifnotmod:
+ return{}
+ # make sure to only return variables actually defined in this
+ # module if available (try to avoid not imports)
+ members=getmembers(mod,predicate=lambdaobj:getmodule(obj)in(mod,None))
+ returndict((key,val)forkey,valinmembersifnotkey.startswith("_"))
+
+
+
[docs]defcallables_from_module(module):
+ """
+ Return all global-level callables defined in a module.
+
+ Args:
+ module (str, module): A python-path to a module or an actual
+ module object.
+
+ Returns:
+ callables (dict): A dict of {name: callable, ...} from the module.
+
+ Notes:
+ Will ignore callables whose names start with underscore "_".
+
+ """
+ mod=mod_import(module)
+ ifnotmod:
+ return{}
+ # make sure to only return callables actually defined in this module (not imports)
+ members=getmembers(mod,predicate=lambdaobj:callable(obj)andgetmodule(obj)==mod)
+ returndict((key,val)forkey,valinmembersifnotkey.startswith("_"))
+
+
+
[docs]defvariable_from_module(module,variable=None,default=None):
+ """
+ Retrieve a variable or list of variables from a module. The
+ variable(s) must be defined globally in the module. If no variable
+ is given (or a list entry is `None`), all global variables are
+ extracted from the module.
+
+ Args:
+ module (string or module): Python path, absolute path or a module.
+ variable (string or iterable, optional): Single variable name or iterable
+ of variable names to extract. If not given, all variables in
+ the module will be returned.
+ default (string, optional): Default value to use if a variable fails to
+ be extracted. Ignored if `variable` is not given.
+
+ Returns:
+ variables (value or list): A single value or a list of values
+ depending on if `variable` is given or not. Errors in lists
+ are replaced by the `default` argument.
+
+ """
+
+ ifnotmodule:
+ returndefault
+
+ mod=mod_import(module)
+
+ ifnotmod:
+ returndefault
+
+ ifvariable:
+ result=[]
+ forvarinmake_iter(variable):
+ ifvar:
+ # try to pick a named variable
+ result.append(mod.__dict__.get(var,default))
+ else:
+ # get all
+ result=[
+ valforkey,valinmod.__dict__.items()ifnot(key.startswith("_")orismodule(val))
+ ]
+
+ iflen(result)==1:
+ returnresult[0]
+ returnresult
+
+
+
[docs]defstring_from_module(module,variable=None,default=None):
+ """
+ This is a wrapper for `variable_from_module` that requires return
+ value to be a string to pass. It's primarily used by login screen.
+
+ Args:
+ module (string or module): Python path, absolute path or a module.
+ variable (string or iterable, optional): Single variable name or iterable
+ of variable names to extract. If not given, all variables in
+ the module will be returned.
+ default (string, optional): Default value to use if a variable fails to
+ be extracted. Ignored if `variable` is not given.
+
+ Returns:
+ variables (value or list): A single (string) value or a list of values
+ depending on if `variable` is given or not. Errors in lists (such
+ as the value not being a string) are replaced by the `default` argument.
+
+ """
+ val=variable_from_module(module,variable=variable,default=default)
+ ifval:
+ ifvariable:
+ returnval
+ else:
+ result=[vforvinmake_iter(val)ifisinstance(v,str)]
+ returnresultifresultelsedefault
+ returndefault
+
+
+
[docs]defrandom_string_from_module(module):
+ """
+ Returns a random global string from a module.
+
+ Args:
+ module (string or module): Python path, absolute path or a module.
+
+ Returns:
+ random (string): A random stribg variable from `module`.
+ """
+ returnrandom.choice(string_from_module(module))
+
+
+
[docs]deffuzzy_import_from_module(path,variable,default=None,defaultpaths=None):
+ """
+ Import a variable based on a fuzzy path. First the literal
+ `path` will be tried, then all given `defaultpaths` will be
+ prepended to see a match is found.
+
+ Args:
+ path (str): Full or partial python path.
+ variable (str): Name of variable to import from module.
+ default (string, optional): Default value to use if a variable fails to
+ be extracted. Ignored if `variable` is not given.
+ defaultpaths (iterable, options): Python paths to attempt in order if
+ importing directly from `path` doesn't work.
+
+ Returns:
+ value (any): The variable imported from the module, or `default`, if
+ not found.
+
+ """
+ paths=[path]+make_iter(defaultpaths)
+ formodpathinpaths:
+ try:
+ mod=importlib.import_module(modpath)
+ exceptImportErrorasex:
+ ifnotstr(ex).startswith("No module named %s"%modpath):
+ # this means the module was found but it
+ # triggers an ImportError on import.
+ raiseex
+ returngetattr(mod,variable,default)
+ returndefault
+
+
+
[docs]defclass_from_module(path,defaultpaths=None,fallback=None):
+ """
+ Return a class from a module, given the module's path. This is
+ primarily used to convert db_typeclass_path:s to classes.
+
+ Args:
+ path (str): Full Python dot-path to module.
+ defaultpaths (iterable, optional): If a direct import from `path` fails,
+ try subsequent imports by prepending those paths to `path`.
+ fallback (str): If all other attempts fail, use this path as a fallback.
+ This is intended as a last-resport. In the example of Evennia
+ loading, this would be a path to a default parent class in the
+ evennia repo itself.
+
+ Returns:
+ class (Class): An uninstatiated class recovered from path.
+
+ Raises:
+ ImportError: If all loading failed.
+
+ """
+ cls=None
+ err=""
+ ifdefaultpaths:
+ paths=(
+ [path]+["%s.%s"%(dpath,path)fordpathinmake_iter(defaultpaths)]
+ ifdefaultpaths
+ else[]
+ )
+ else:
+ paths=[path]
+
+ fortestpathinpaths:
+ if"."inpath:
+ testpath,clsname=testpath.rsplit(".",1)
+ else:
+ raiseImportError("the path '%s' is not on the form modulepath.Classname."%path)
+
+ try:
+ ifnotimportlib.util.find_spec(testpath,package="evennia"):
+ continue
+ exceptModuleNotFoundError:
+ continue
+
+ try:
+ mod=importlib.import_module(testpath,package="evennia")
+ exceptModuleNotFoundError:
+ err=traceback.format_exc(30)
+ break
+
+ try:
+ cls=getattr(mod,clsname)
+ break
+ exceptAttributeError:
+ iflen(trace())>2:
+ # AttributeError within the module, don't hide it
+ err=traceback.format_exc(30)
+ break
+ ifnotcls:
+ err="\nCould not load typeclass '{}'{}".format(
+ path," with the following traceback:\n"+erriferrelse""
+ )
+ ifdefaultpaths:
+ err+="\nPaths searched:\n%s"%"\n ".join(paths)
+ else:
+ err+="."
+ logger.log_err(err)
+ iffallback:
+ logger.log_warn(f"Falling back to {fallback}.")
+ returnclass_from_module(fallback)
+ else:
+ # even fallback fails
+ raiseImportError(err)
+ returncls
+
+
+# alias
+object_from_module=class_from_module
+
+
+
[docs]definit_new_account(account):
+ """
+ Deprecated.
+ """
+ fromevennia.utilsimportlogger
+
+ logger.log_dep("evennia.utils.utils.init_new_account is DEPRECATED and should not be used.")
+
+
+
[docs]defstring_similarity(string1,string2):
+ """
+ This implements a "cosine-similarity" algorithm as described for example in
+ *Proceedings of the 22nd International Conference on Computation
+ Linguistics* (Coling 2008), pages 593-600, Manchester, August 2008.
+ The measure-vectors used is simply a "bag of words" type histogram
+ (but for letters).
+
+ Args:
+ string1 (str): String to compare (may contain any number of words).
+ string2 (str): Second string to compare (any number of words).
+
+ Returns:
+ similarity (float): A value 0...1 rating how similar the two
+ strings are.
+
+ """
+ vocabulary=set(list(string1+string2))
+ vec1=[string1.count(v)forvinvocabulary]
+ vec2=[string2.count(v)forvinvocabulary]
+ try:
+ returnfloat(sum(vec1[i]*vec2[i]foriinrange(len(vocabulary))))/(
+ math.sqrt(sum(v1**2forv1invec1))*math.sqrt(sum(v2**2forv2invec2))
+ )
+ exceptZeroDivisionError:
+ # can happen if empty-string cmdnames appear for some reason.
+ # This is a no-match.
+ return0
+
+
+
[docs]defstring_suggestions(string,vocabulary,cutoff=0.6,maxnum=3):
+ """
+ Given a `string` and a `vocabulary`, return a match or a list of
+ suggestions based on string similarity.
+
+ Args:
+ string (str): A string to search for.
+ vocabulary (iterable): A list of available strings.
+ cutoff (int, 0-1): Limit the similarity matches (the higher
+ the value, the more exact a match is required).
+ maxnum (int): Maximum number of suggestions to return.
+
+ Returns:
+ suggestions (list): Suggestions from `vocabulary` with a
+ similarity-rating that higher than or equal to `cutoff`.
+ Could be empty if there are no matches.
+
+ """
+ return[
+ tup[1]
+ fortupinsorted(
+ [(string_similarity(string,sugg),sugg)forsugginvocabulary],
+ key=lambdatup:tup[0],
+ reverse=True,
+ )
+ iftup[0]>=cutoff
+ ][:maxnum]
+
+
+
[docs]defstring_partial_matching(alternatives,inp,ret_index=True):
+ """
+ Partially matches a string based on a list of `alternatives`.
+ Matching is made from the start of each subword in each
+ alternative. Case is not important. So e.g. "bi sh sw" or just
+ "big" or "shiny" or "sw" will match "Big shiny sword". Scoring is
+ done to allow to separate by most common demoninator. You will get
+ multiple matches returned if appropriate.
+
+ Args:
+ alternatives (list of str): A list of possible strings to
+ match.
+ inp (str): Search criterion.
+ ret_index (bool, optional): Return list of indices (from alternatives
+ array) instead of strings.
+ Returns:
+ matches (list): String-matches or indices if `ret_index` is `True`.
+
+ """
+ ifnotalternativesornotinp:
+ return[]
+
+ matches=defaultdict(list)
+ inp_words=inp.lower().split()
+ foraltindex,altinenumerate(alternatives):
+ alt_words=alt.lower().split()
+ last_index=0
+ score=0
+ forinp_wordininp_words:
+ # loop over parts, making sure only to visit each part once
+ # (this will invalidate input in the wrong word order)
+ submatch=[
+ last_index+alt_num
+ foralt_num,alt_wordinenumerate(alt_words[last_index:])
+ ifalt_word.startswith(inp_word)
+ ]
+ ifsubmatch:
+ last_index=min(submatch)+1
+ score+=1
+ else:
+ score=0
+ break
+ ifscore:
+ ifret_index:
+ matches[score].append(altindex)
+ else:
+ matches[score].append(alt)
+ ifmatches:
+ returnmatches[max(matches)]
+ return[]
+
+
+
[docs]defformat_table(table,extra_space=1):
+ """
+ Note: `evennia.utils.evtable` is more powerful than this, but this function
+ can be useful when the number of columns and rows are unknown and must be
+ calculated on the fly.
+
+ Args.
+ table (list): A list of lists to represent columns in the
+ table: `[[val,val,val,...], [val,val,val,...], ...]`, where
+ each val will be placed on a separate row in the
+ column. All columns must have the same number of rows (some
+ positions may be empty though).
+ extra_space (int, optional): Sets how much *minimum* extra
+ padding (in characters) should be left between columns.
+
+ Returns:
+ table (list): A list of lists representing the rows to print
+ out one by one.
+
+ Notes:
+ The function formats the columns to be as wide as the widest member
+ of each column.
+
+ Example:
+ ::
+
+ ftable = format_table([[...], [...], ...])
+ for ir, row in enumarate(ftable):
+ if ir == 0:
+ # make first row white
+ string += "\\\\n|w" + ""join(row) + "|n"
+ else:
+ string += "\\\\n" + "".join(row)
+ print(string)
+
+ """
+ ifnottable:
+ return[[]]
+
+ max_widths=[max([len(str(val))forvalincol])forcolintable]
+ ftable=[]
+ forirowinrange(len(table[0])):
+ ftable.append(
+ [
+ str(col[irow]).ljust(max_widths[icol])+" "*extra_space
+ foricol,colinenumerate(table)
+ ]
+ )
+ returnftable
+
+
+
[docs]defget_evennia_pids():
+ """
+ Get the currently valid PIDs (Process IDs) of the Portal and
+ Server by trying to access a PID file.
+
+ Returns:
+ server, portal (tuple): The PIDs of the respective processes,
+ or two `None` values if not found.
+
+ Examples:
+ This can be used to determine if we are in a subprocess by
+ something like:
+
+ ```python
+ self_pid = os.getpid()
+ server_pid, portal_pid = get_evennia_pids()
+ is_subprocess = self_pid not in (server_pid, portal_pid)
+ ```
+ """
+ server_pidfile=os.path.join(settings.GAME_DIR,"server.pid")
+ portal_pidfile=os.path.join(settings.GAME_DIR,"portal.pid")
+ server_pid,portal_pid=None,None
+ ifos.path.exists(server_pidfile):
+ withopen(server_pidfile,"r")asf:
+ server_pid=f.read()
+ ifos.path.exists(portal_pidfile):
+ withopen(portal_pidfile,"r")asf:
+ portal_pid=f.read()
+ ifserver_pidandportal_pid:
+ returnint(server_pid),int(portal_pid)
+ returnNone,None
+
+
+
[docs]defdeepsize(obj,max_depth=4):
+ """
+ Get not only size of the given object, but also the size of
+ objects referenced by the object, down to `max_depth` distance
+ from the object.
+
+ Args:
+ obj (object): the object to be measured.
+ max_depth (int, optional): maximum referential distance
+ from `obj` that `deepsize()` should cover for
+ measuring objects referenced by `obj`.
+
+ Returns:
+ size (int): deepsize of `obj` in Bytes.
+
+ Notes:
+ This measure is necessarily approximate since some
+ memory is shared between objects. The `max_depth` of 4 is roughly
+ tested to give reasonable size information about database models
+ and their handlers.
+
+ """
+
+ def_recurse(o,dct,depth):
+ if0<=max_depth<depth:
+ return
+ forrefingc.get_referents(o):
+ idr=id(ref)
+ ifidrnotindct:
+ dct[idr]=(ref,sys.getsizeof(ref,default=0))
+ _recurse(ref,dct,depth+1)
+
+ sizedict={}
+ _recurse(obj,sizedict,0)
+ size=sys.getsizeof(obj)+sum([p[1]forpinsizedict.values()])
+ returnsize
+
+
+# lazy load handler
+_missing=object()
+
+
+
[docs]classlazy_property(object):
+ """
+ Delays loading of property until first access. Credit goes to the
+ Implementation in the werkzeug suite:
+ http://werkzeug.pocoo.org/docs/utils/#werkzeug.utils.cached_property
+
+ This should be used as a decorator in a class and in Evennia is
+ mainly used to lazy-load handlers:
+
+ ```python
+ @lazy_property
+ def attributes(self):
+ return AttributeHandler(self)
+ ```
+
+ Once initialized, the `AttributeHandler` will be available as a
+ property "attributes" on the object.
+
+ """
+
+
[docs]def__init__(self,func,name=None,doc=None):
+ """Store all properties for now"""
+ self.__name__=nameorfunc.__name__
+ self.__module__=func.__module__
+ self.__doc__=docorfunc.__doc__
+ self.func=func
[docs]defstrip_control_sequences(string):
+ """
+ Remove non-print text sequences.
+
+ Args:
+ string (str): Text to strip.
+
+ Returns.
+ text (str): Stripped text.
+
+ """
+ global_STRIP_ANSI
+ ifnot_STRIP_ANSI:
+ fromevennia.utils.ansiimportstrip_raw_ansias_STRIP_ANSI
+ return_RE_CONTROL_CHAR.sub("",_STRIP_ANSI(string))
+
+
+
[docs]defcalledby(callerdepth=1):
+ """
+ Only to be used for debug purposes. Insert this debug function in
+ another function; it will print which function called it.
+
+ Args:
+ callerdepth (int): Must be larger than 0. When > 1, it will
+ print the caller of the caller etc.
+
+ Returns:
+ calledby (str): A debug string detailing which routine called
+ us.
+
+ """
+ importinspect
+
+ stack=inspect.stack()
+ # we must step one extra level back in stack since we don't want
+ # to include the call of this function itself.
+ callerdepth=min(max(2,callerdepth+1),len(stack)-1)
+ frame=inspect.stack()[callerdepth]
+ path=os.path.sep.join(frame[1].rsplit(os.path.sep,2)[-2:])
+ return"[called by '%s': %s:%s%s]"%(frame[3],path,frame[2],frame[4])
+
+
+
[docs]defm_len(target):
+ """
+ Provides length checking for strings with MXP patterns, and falls
+ back to normal len for other objects.
+
+ Args:
+ target (str): A string with potential MXP components
+ to search.
+
+ Returns:
+ length (int): The length of `target`, ignoring MXP components.
+
+ """
+ # Would create circular import if in module root.
+ fromevennia.utils.ansiimportANSI_PARSER
+
+ ifinherits_from(target,str)and"|lt"intarget:
+ returnlen(ANSI_PARSER.strip_mxp(target))
+ returnlen(target)
+
+
+
[docs]defdisplay_len(target):
+ """
+ Calculate the 'visible width' of text. This is not necessarily the same as the
+ number of characters in the case of certain asian characters. This will also
+ strip MXP patterns.
+
+ Args:
+ target (any): Something to measure the length of. If a string, it will be
+ measured keeping asian-character and MXP links in mind.
+
+ Return:
+ int: The visible width of the target.
+
+ """
+ # Would create circular import if in module root.
+ fromevennia.utils.ansiimportANSI_PARSER
+
+ ifinherits_from(target,str):
+ # str or ANSIString
+ target=ANSI_PARSER.strip_mxp(target)
+ target=ANSI_PARSER.parse_ansi(target,strip_ansi=True)
+ extra_wide=("F","W")
+ returnsum(2ifeast_asian_width(char)inextra_wideelse1forcharintarget)
+ else:
+ returnlen(target)
+
+
+# -------------------------------------------------------------------
+# Search handler function
+# -------------------------------------------------------------------
+#
+# Replace this hook function by changing settings.SEARCH_AT_RESULT.
+
+
+
[docs]defat_search_result(matches,caller,query="",quiet=False,**kwargs):
+ """
+ This is a generic hook for handling all processing of a search
+ result, including error reporting. This is also called by the cmdhandler
+ to manage errors in command lookup.
+
+ Args:
+ matches (list): This is a list of 0, 1 or more typeclass
+ instances or Command instances, the matched result of the
+ search. If 0, a nomatch error should be echoed, and if >1,
+ multimatch errors should be given. Only if a single match
+ should the result pass through.
+ caller (Object): The object performing the search and/or which should
+ receive error messages.
+ query (str, optional): The search query used to produce `matches`.
+ quiet (bool, optional): If `True`, no messages will be echoed to caller
+ on errors.
+
+ Keyword Args:
+ nofound_string (str): Replacement string to echo on a notfound error.
+ multimatch_string (str): Replacement string to echo on a multimatch error.
+
+ Returns:
+ processed_result (Object or None): This is always a single result
+ or `None`. If `None`, any error reporting/handling should
+ already have happened. The returned object is of the type we are
+ checking multimatches for (e.g. Objects or Commands)
+
+ """
+
+ error=""
+ ifnotmatches:
+ # no results.
+ error=kwargs.get("nofound_string")or_("Could not find '%s'."%query)
+ matches=None
+ eliflen(matches)>1:
+ multimatch_string=kwargs.get("multimatch_string")
+ ifmultimatch_string:
+ error="%s\n"%multimatch_string
+ else:
+ error=_("More than one match for '{query}' (please narrow target):\n").format(
+ query=query
+ )
+
+ fornum,resultinenumerate(matches):
+ # we need to consider Commands, where .aliases is a list
+ aliases=result.aliases.all()ifhasattr(result.aliases,"all")elseresult.aliases
+ # remove any pluralization aliases
+ aliases=[
+ alias
+ foraliasinaliases
+ ifhasattr(alias,"category")andalias.categorynotin("plural_key",)
+ ]
+ error+=_MULTIMATCH_TEMPLATE.format(
+ number=num+1,
+ name=result.get_display_name(caller)
+ ifhasattr(result,"get_display_name")
+ elsequery,
+ aliases=" [%s]"%";".join(aliases)ifaliaseselse"",
+ info=result.get_extra_info(caller),
+ )
+ matches=None
+ else:
+ # exactly one match
+ matches=matches[0]
+
+ iferrorandnotquiet:
+ caller.msg(error.strip())
+ returnmatches
+
+
+
[docs]classLimitedSizeOrderedDict(OrderedDict):
+ """
+ This dictionary subclass is both ordered and limited to a maximum
+ number of elements. Its main use is to hold a cache that can never
+ grow out of bounds.
+
+ """
+
+
[docs]def__init__(self,*args,**kwargs):
+ """
+ Limited-size ordered dict.
+
+ Keyword Args:
+ size_limit (int): Use this to limit the number of elements
+ alloweds to be in this list. By default the overshooting elements
+ will be removed in FIFO order.
+ fifo (bool, optional): Defaults to `True`. Remove overshooting elements
+ in FIFO order. If `False`, remove in FILO order.
+
+ """
+ super().__init__()
+ self.size_limit=kwargs.get("size_limit",None)
+ self.filo=notkwargs.get("fifo",True)# FIFO inverse of FILO
+ self._check_size()
[docs]defget_game_dir_path():
+ """
+ This is called by settings_default in order to determine the path
+ of the game directory.
+
+ Returns:
+ path (str): Full OS path to the game dir
+
+ """
+ # current working directory, assumed to be somewhere inside gamedir.
+ for_inrange(10):
+ gpath=os.getcwd()
+ if"server"inos.listdir(gpath):
+ ifos.path.isfile(os.path.join("server","conf","settings.py")):
+ returngpath
+ else:
+ os.chdir(os.pardir)
+ raiseRuntimeError("server/conf/settings.py not found: Must start from inside game dir.")
+
+
+
[docs]defget_all_typeclasses(parent=None):
+ """
+ List available typeclasses from all available modules.
+
+ Args:
+ parent (str, optional): If given, only return typeclasses inheriting (at any distance)
+ from this parent.
+
+ Returns:
+ typeclasses (dict): On the form {"typeclass.path": typeclass, ...}
+
+ Notes:
+ This will dynamicall retrieve all abstract django models inheriting at any distance
+ from the TypedObject base (aka a Typeclass) so it will work fine with any custom
+ classes being added.
+
+ """
+ fromevennia.typeclasses.modelsimportTypedObject
+
+ typeclasses={
+ "{}.{}".format(model.__module__,model.__name__):model
+ formodelinapps.get_models()
+ ifTypedObjectingetmro(model)
+ }
+ ifparent:
+ typeclasses={
+ name:typeclass
+ forname,typeclassintypeclasses.items()
+ ifinherits_from(typeclass,parent)
+ }
+ returntypeclasses
+
+
+
[docs]definteractive(func):
+ """
+ Decorator to make a method pausable with yield(seconds) and able to ask for
+ user-input with `response=yield(question)`. For the question-asking to
+ work, 'caller' must the name of an argument or kwarg to the decorated
+ function.
+
+ Example:
+ ::
+
+ @interactive
+ def myfunc(caller):
+ caller.msg("This is a test")
+ # wait five seconds
+ yield(5)
+ # ask user (caller) a question
+ response = yield("Do you want to continue waiting?")
+ if response == "yes":
+ yield(5)
+ else:
+ # ...
+
+ Notes:
+ This turns the method into a generator!
+
+ """
+ fromevennia.utils.evmenuimportget_input
+
+ def_process_input(caller,prompt,result,generator):
+ deferLater(reactor,0,_iterate,generator,caller,response=result)
+ returnFalse
+
+ def_iterate(generator,caller=None,response=None):
+ try:
+ ifresponseisNone:
+ value=next(generator)
+ else:
+ value=generator.send(response)
+ exceptStopIteration:
+ pass
+ else:
+ ifisinstance(value,(int,float)):
+ delay(value,_iterate,generator,caller=caller)
+ elifisinstance(value,str):
+ ifnotcaller:
+ raiseValueError(
+ "To retrieve input from a @pausable method, that method "
+ "must be called with a 'caller' argument)"
+ )
+ get_input(caller,value,_process_input,generator=generator)
+ else:
+ raiseValueError("yield(val) in a @pausable method must have an int/float as arg.")
+
+ defdecorator(*args,**kwargs):
+ argnames=inspect.getfullargspec(func).args
+ caller=None
+ if"caller"inargnames:
+ # we assume this is an object
+ caller=args[argnames.index("caller")]
+
+ ret=func(*args,**kwargs)
+ ifisinstance(ret,types.GeneratorType):
+ _iterate(ret,caller)
+ else:
+ returnret
+
+ returndecorator
+"""
+Contains all the validation functions.
+
+All validation functions must have a checker (probably a session) and entry arg.
+
+They can employ more paramters at your leisure.
+
+
+"""
+
+importreas_re
+importpytzas_pytz
+importdatetimeas_dt
+fromevennia.utils.ansiimportstrip_ansi
+fromevennia.utils.utilsimportstring_partial_matchingas_partial,validate_email_address
+fromdjango.utils.translationimportgettextas_
+
+_TZ_DICT={str(tz):_pytz.timezone(tz)fortzin_pytz.common_timezones}
+
+
+
[docs]deftext(entry,option_key="Text",**kwargs):
+ try:
+ returnstr(entry)
+ exceptExceptionaserr:
+ raiseValueError(f"Input could not be converted to text ({err})")
+
+
+
[docs]defcolor(entry,option_key="Color",**kwargs):
+ """
+ The color should be just a color character, so 'r' if red color is desired.
+ """
+ ifnotentry:
+ raiseValueError(f"Nothing entered for a {option_key}!")
+ test_str=strip_ansi(f"|{entry}|n")
+ iftest_str:
+ raiseValueError(f"'{entry}' is not a valid {option_key}.")
+ returnentry
+
+
+
[docs]defdatetime(entry,option_key="Datetime",account=None,from_tz=None,**kwargs):
+ """
+ Process a datetime string in standard forms while accounting for the
+ inputer's timezone. Always returns a result in UTC.
+
+ Args:
+ entry (str): A date string from a user.
+ option_key (str): Name to display this datetime as.
+ account (AccountDB): The Account performing this lookup. Unless `from_tz` is provided,
+ the account's timezone option will be used.
+ from_tz (pytz.timezone): An instance of a pytz timezone object from the
+ user. If not provided, tries to use the timezone option of the `account`.
+ If neither one is provided, defaults to UTC.
+
+ Returns:
+ datetime in UTC.
+
+ Raises:
+ ValueError: If encountering a malformed timezone, date string or other format error.
+
+ """
+ ifnotentry:
+ raiseValueError(_("No {option_key} entered!").format(option_key=option_key))
+ ifnotfrom_tz:
+ from_tz=_pytz.UTC
+ ifaccount:
+ acct_tz=account.options.get("timezone","UTC")
+ try:
+ from_tz=_pytz.timezone(acct_tz)
+ exceptExceptionaserr:
+ raiseValueError(
+ _("Timezone string '{acct_tz}' is not a valid timezone ({err})").format(
+ acct_tz=acct_tz,err=err
+ )
+ )
+ else:
+ from_tz=_pytz.UTC
+
+ utc=_pytz.UTC
+ now=_dt.datetime.utcnow().replace(tzinfo=utc)
+ cur_year=now.strftime("%Y")
+ split_time=entry.split(" ")
+ iflen(split_time)==3:
+ entry=f"{split_time[0]}{split_time[1]}{split_time[2]}{cur_year}"
+ eliflen(split_time)==4:
+ entry=f"{split_time[0]}{split_time[1]}{split_time[2]}{split_time[3]}"
+ else:
+ raiseValueError(
+ f"{option_key} must be entered in a 24-hour format such as: {now.strftime('%b %d %H:%M')}"
+ )
+ try:
+ local=_dt.datetime.strptime(entry,"%b %d %H:%M %Y")
+ exceptValueError:
+ raiseValueError(
+ f"{option_key} must be entered in a 24-hour format such as: {now.strftime('%b %d %H:%M')}"
+ )
+ local_tz=from_tz.localize(local)
+ returnlocal_tz.astimezone(utc)
+
+
+
[docs]defduration(entry,option_key="Duration",**kwargs):
+ """
+ Take a string and derive a datetime timedelta from it.
+
+ Args:
+ entry (string): This is a string from user-input. The intended format is, for example: "5d 2w 90s" for
+ 'five days, two weeks, and ninety seconds.' Invalid sections are ignored.
+ option_key (str): Name to display this query as.
+
+ Returns:
+ timedelta
+
+ """
+ time_string=entry.lower().split(" ")
+ seconds=0
+ minutes=0
+ hours=0
+ days=0
+ weeks=0
+
+ forintervalintime_string:
+ if_re.match(r"^[\d]+s$",interval):
+ seconds+=int(interval.rstrip("s"))
+ elif_re.match(r"^[\d]+m$",interval):
+ minutes+=int(interval.rstrip("m"))
+ elif_re.match(r"^[\d]+h$",interval):
+ hours+=int(interval.rstrip("h"))
+ elif_re.match(r"^[\d]+d$",interval):
+ days+=int(interval.rstrip("d"))
+ elif_re.match(r"^[\d]+w$",interval):
+ weeks+=int(interval.rstrip("w"))
+ elif_re.match(r"^[\d]+y$",interval):
+ days+=int(interval.rstrip("y"))*365
+ else:
+ raiseValueError(f"Could not convert section '{interval}' to a {option_key}.")
+
+ return_dt.timedelta(days,seconds,0,0,minutes,hours,weeks)
+
+
+
[docs]deffuture(entry,option_key="Future Datetime",from_tz=None,**kwargs):
+ time=datetime(entry,option_key,from_tz=from_tz)
+ iftime<_dt.datetime.utcnow().replace(tzinfo=_dt.timezone.utc):
+ raiseValueError(f"That {option_key} is in the past! Must give a Future datetime!")
+ returntime
+
+
+
[docs]defsigned_integer(entry,option_key="Signed Integer",**kwargs):
+ ifnotentry:
+ raiseValueError(f"Must enter a whole number for {option_key}!")
+ try:
+ num=int(entry)
+ exceptValueError:
+ raiseValueError(f"Could not convert '{entry}' to a whole number for {option_key}!")
+ returnnum
+
+
+
[docs]defpositive_integer(entry,option_key="Positive Integer",**kwargs):
+ num=signed_integer(entry,option_key)
+ ifnotnum>=1:
+ raiseValueError(f"Must enter a whole number greater than 0 for {option_key}!")
+ returnnum
+
+
+
[docs]defunsigned_integer(entry,option_key="Unsigned Integer",**kwargs):
+ num=signed_integer(entry,option_key)
+ ifnotnum>=0:
+ raiseValueError(f"{option_key} must be a whole number greater than or equal to 0!")
+ returnnum
+
+
+
[docs]defboolean(entry,option_key="True/False",**kwargs):
+ """
+ Simplest check in computer logic, right? This will take user input to flick the switch on or off
+ Args:
+ entry (str): A value such as True, On, Enabled, Disabled, False, 0, or 1.
+ option_key (str): What kind of Boolean we are setting. What Option is this for?
+
+ Returns:
+ Boolean
+ """
+ error=f"Must enter 0 (false) or 1 (true) for {option_key}. Also accepts True, False, On, Off, Yes, No, Enabled, and Disabled"
+ ifnotisinstance(entry,str):
+ raiseValueError(error)
+ entry=entry.upper()
+ ifentryin("1","TRUE","ON","ENABLED","ENABLE","YES"):
+ returnTrue
+ ifentryin("0","FALSE","OFF","DISABLED","DISABLE","NO"):
+ returnFalse
+ raiseValueError(error)
+
+
+
[docs]deftimezone(entry,option_key="Timezone",**kwargs):
+ """
+ Takes user input as string, and partial matches a Timezone.
+
+ Args:
+ entry (str): The name of the Timezone.
+ option_key (str): What this Timezone is used for.
+
+ Returns:
+ A PYTZ timezone.
+ """
+ ifnotentry:
+ raiseValueError(f"No {option_key} entered!")
+ found=_partial(list(_TZ_DICT.keys()),entry,ret_index=False)
+ iflen(found)>1:
+ raiseValueError(
+ f"That matched: {', '.join(str(t)fortinfound)}. Please be more specific!"
+ )
+ iffound:
+ return_TZ_DICT[found[0]]
+ raiseValueError(f"Could not find timezone '{entry}' for {option_key}!")
[docs]classCaseInsensitiveModelBackend(ModelBackend):
+ """
+ By default ModelBackend does case _sensitive_ username
+ authentication, which isn't what is generally expected. This
+ backend supports case insensitive username authentication.
+
+ """
+
+
[docs]defauthenticate(self,request,username=None,password=None,autologin=None):
+ """
+ Custom authenticate with bypass for auto-logins
+
+ Args:
+ request (Request): Request object.
+ username (str, optional): Name of user to authenticate.
+ password (str, optional): Password of user
+ autologin (Account, optional): If given, assume this is
+ an already authenticated account and bypass authentication.
+ """
+ ifautologin:
+ # Note: Setting .backend on account is critical in order to
+ # be allowed to call django.auth.login(account) later. This
+ # is necessary for the auto-login feature of the webclient,
+ # but it's important to make sure Django doesn't change this
+ # requirement or the name of the property down the line. /Griatch
+ autologin.backend="evennia.web.utils.backends.CaseInsensitiveModelBackend"
+ returnautologin
+ else:
+ # In this case .backend will be assigned automatically
+ # somewhere along the way.
+ Account=get_user_model()
+ try:
+ account=Account.objects.get(username__iexact=username)
+ ifaccount.check_password(password):
+ returnaccount
+ else:
+ returnNone
+ exceptAccount.DoesNotExist:
+ returnNone
+#
+# This file defines global variables that will always be
+# available in a view context without having to repeatedly
+# include it. For this to work, this file is included in
+# the settings file, in the TEMPLATE_CONTEXT_PROCESSORS
+# tuple.
+#
+
+importos
+fromdjango.confimportsettings
+fromevennia.utils.utilsimportget_evennia_version
+
+# Determine the site name and server version
+
[docs]defset_game_name_and_slogan():
+ """
+ Sets global variables GAME_NAME and GAME_SLOGAN which are used by
+ general_context.
+
+ Notes:
+ This function is used for unit testing the values of the globals.
+ """
+ globalGAME_NAME,GAME_SLOGAN,SERVER_VERSION
+ try:
+ GAME_NAME=settings.SERVERNAME.strip()
+ exceptAttributeError:
+ GAME_NAME="Evennia"
+ SERVER_VERSION=get_evennia_version()
+ try:
+ GAME_SLOGAN=settings.GAME_SLOGAN.strip()
+ exceptAttributeError:
+ GAME_SLOGAN=SERVER_VERSION
+
+
+set_game_name_and_slogan()
+
+# Setup lists of the most relevant apps so
+# the adminsite becomes more readable.
+
+ACCOUNT_RELATED=["Accounts"]
+GAME_ENTITIES=["Objects","Scripts","Comms","Help"]
+GAME_SETUP=["Permissions","Config"]
+CONNECTIONS=["Irc"]
+WEBSITE=["Flatpages","News","Sites"]
+
+
+
[docs]defset_webclient_settings():
+ """
+ As with set_game_name_and_slogan above, this sets global variables pertaining
+ to webclient settings.
+
+ Notes:
+ Used for unit testing.
+ """
+ globalWEBCLIENT_ENABLED,WEBSOCKET_CLIENT_ENABLED,WEBSOCKET_PORT,WEBSOCKET_URL
+ WEBCLIENT_ENABLED=settings.WEBCLIENT_ENABLED
+ WEBSOCKET_CLIENT_ENABLED=settings.WEBSOCKET_CLIENT_ENABLED
+ # if we are working through a proxy or uses docker port-remapping, the webclient port encoded
+ # in the webclient should be different than the one the server expects. Use the environment
+ # variable WEBSOCKET_CLIENT_PROXY_PORT if this is the case.
+ WEBSOCKET_PORT=int(
+ os.environ.get("WEBSOCKET_CLIENT_PROXY_PORT",settings.WEBSOCKET_CLIENT_PORT)
+ )
+ # this is determined dynamically by the client and is less of an issue
+ WEBSOCKET_URL=settings.WEBSOCKET_CLIENT_URL
+
+
+set_webclient_settings()
+
+# The main context processor function
+
[docs]classSharedLoginMiddleware(object):
+ """
+ Handle the shared login between website and webclient.
+
+ """
+
+
[docs]def__init__(self,get_response):
+ # One-time configuration and initialization.
+ self.get_response=get_response
+
+ def__call__(self,request):
+ # Code to be executed for each request before
+ # the view (and later middleware) are called.
+
+ # Synchronize credentials between webclient and website
+ # Must be performed *before* rendering the view (issue #1723)
+ self.make_shared_login(request)
+
+ # Process view
+ response=self.get_response(request)
+
+ # Code to be executed for each request/response after
+ # the view is called.
+
+ # Return processed view
+ returnresponse
+
+
[docs]@classmethod
+ defmake_shared_login(cls,request):
+ csession=request.session
+ account=request.user
+ website_uid=csession.get("website_authenticated_uid",None)
+ webclient_uid=csession.get("webclient_authenticated_uid",None)
+
+ ifnotcsession.session_key:
+ # this is necessary to build the sessid key
+ csession.save()
+
+ ifaccount.is_authenticated:
+ # Logged into website
+ ifwebsite_uidisNone:
+ # fresh website login (just from login page)
+ csession["website_authenticated_uid"]=account.id
+ ifwebclient_uidisNone:
+ # auto-login web client
+ csession["webclient_authenticated_uid"]=account.id
+
+ elifwebclient_uid:
+ # Not logged into website, but logged into webclient
+ ifwebsite_uidisNone:
+ csession["website_authenticated_uid"]=account.id
+ account=AccountDB.objects.get(id=webclient_uid)
+ try:
+ # calls our custom authenticate, in web/utils/backend.py
+ authenticate(autologin=account)
+ login(request,account)
+ exceptAttributeError:
+ logger.log_trace()
+
+ ifcsession.get("webclient_authenticated_uid",None):
+ # set a nonce to prevent the webclient from erasing the webclient_authenticated_uid value
+ csession["webclient_authenticated_nonce"]=(
+ csession.get("webclient_authenticated_nonce",0)+1
+ )
+ # wrap around to prevent integer overflows
+ ifcsession["webclient_authenticated_nonce"]>32:
+ csession["webclient_authenticated_nonce"]=0
+"""
+This contains a simple view for rendering the webclient
+page and serve it eventual static content.
+
+"""
+
+fromdjango.confimportsettings
+fromdjango.httpimportHttp404
+fromdjango.shortcutsimportrender
+fromdjango.contrib.authimportlogin,authenticate
+
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.utilsimportlogger
+
+
+
[docs]defwebclient(request):
+ """
+ Webclient page template loading.
+
+ """
+ # auto-login is now handled by evennia.web.utils.middleware
+
+ # check if webclient should be enabled
+ ifnotsettings.WEBCLIENT_ENABLED:
+ raiseHttp404
+
+ # make sure to store the browser session's hash so the webclient can get to it!
+ pagevars={"browser_sessid":request.session.session_key}
+
+ returnrender(request,"webclient.html",pagevars)
[docs]classEvenniaForm(forms.Form):
+ """
+ This is a stock Django form, but modified so that all values provided
+ through it are escaped (sanitized). Validation is performed by the fields
+ you define in the form.
+
+ This has little to do with Evennia itself and is more general web security-
+ related.
+
+ https://www.owasp.org/index.php/Input_Validation_Cheat_Sheet#Goals_of_Input_Validation
+
+ """
+
+
[docs]defclean(self):
+ """
+ Django hook. Performed on form submission.
+
+ Returns:
+ cleaned (dict): Dictionary of key:value pairs submitted on the form.
+
+ """
+ # Call parent function
+ cleaned=super(EvenniaForm,self).clean()
+
+ # Escape all values provided by user
+ cleaned={k:escape(v)fork,vincleaned.items()}
+ returncleaned
+
+
+
[docs]classAccountForm(UserCreationForm):
+ """
+ This is a generic Django form tailored to the Account model.
+
+ In this incarnation it does not allow getting/setting of attributes, only
+ core User model fields (username, email, password).
+
+ """
+
+
[docs]classMeta:
+ """
+ This is a Django construct that provides additional configuration to
+ the form.
+
+ """
+
+ # The model/typeclass this form creates
+ model=class_from_module(settings.BASE_ACCOUNT_TYPECLASS,
+ fallback=settings.FALLBACK_ACCOUNT_TYPECLASS)
+
+ # The fields to display on the form, in the given order
+ fields=("username","email")
+
+ # Any overrides of field classes
+ field_classes={"username":UsernameField}
+
+ # Username is collected as part of the core UserCreationForm, so we just need
+ # to add a field to (optionally) capture email.
+ email=forms.EmailField(
+ help_text="A valid email address. Optional; used for password resets.",required=False
+ )
+
+
+
[docs]classObjectForm(EvenniaForm,ModelForm):
+ """
+ This is a Django form for generic Evennia Objects that allows modification
+ of attributes when called from a descendent of ObjectUpdate or ObjectCreate
+ views.
+
+ It defines no fields by default; you have to do that by extending this class
+ and defining what fields you want to be recorded. See the CharacterForm for
+ a simple example of how to do this.
+
+ """
+
+
[docs]classMeta:
+ """
+ This is a Django construct that provides additional configuration to
+ the form.
+
+ """
+
+ # The model/typeclass this form creates
+ model=class_from_module(settings.BASE_OBJECT_TYPECLASS,
+ fallback=settings.FALLBACK_OBJECT_TYPECLASS)
+
+ # The fields to display on the form, in the given order
+ fields=("db_key",)
+
+ # This lets us rename ugly db-specific keys to something more human
+ labels={"db_key":"Name"}
+
+
+
[docs]classCharacterForm(ObjectForm):
+ """
+ This is a Django form for Evennia Character objects.
+
+ Since Evennia characters only have one attribute by default, this form only
+ defines a field for that single attribute. The names of fields you define should
+ correspond to their names as stored in the dbhandler; you can display
+ 'prettier' versions of the fieldname on the form using the 'label' kwarg.
+
+ The basic field types are CharFields and IntegerFields, which let you enter
+ text and numbers respectively. IntegerFields have some neat validation tricks
+ they can do, like mandating values fall within a certain range.
+
+ For example, a complete "age" field (which stores its value to
+ `character.db.age` might look like:
+
+ age = forms.IntegerField(
+ label="Your Age",
+ min_value=18, max_value=9000,
+ help_text="Years since your birth.")
+
+ Default input fields are generic single-line text boxes. You can control what
+ sort of input field users will see by specifying a "widget." An example of
+ this is used for the 'desc' field to show a Textarea box instead of a Textbox.
+
+ For help in building out your form, please see:
+ https://docs.djangoproject.com/en/1.11/topics/forms/#building-a-form-in-django
+
+ For more information on fields and their capabilities, see:
+ https://docs.djangoproject.com/en/1.11/ref/forms/fields/
+
+ For more on widgets, see:
+ https://docs.djangoproject.com/en/1.11/ref/forms/widgets/
+
+ """
+
+
[docs]classMeta:
+ """
+ This is a Django construct that provides additional configuration to
+ the form.
+
+ """
+
+ # Get the correct object model
+ model=class_from_module(settings.BASE_CHARACTER_TYPECLASS,
+ fallback=settings.FALLBACK_CHARACTER_TYPECLASS)
+
+ # Allow entry of the 'key' field
+ fields=("db_key",)
+
+ # Rename 'key' to something more intelligible
+ labels={"db_key":"Name"}
+
+ # Fields pertaining to configurable attributes on the Character object.
+ desc=forms.CharField(
+ label="Description",
+ max_length=2048,
+ required=False,
+ widget=forms.Textarea(attrs={"rows":3}),
+ help_text="A brief description of your character.",
+ )
+
+
+
[docs]classCharacterUpdateForm(CharacterForm):
+ """
+ This is a Django form for updating Evennia Character objects.
+
+ By default it is the same as the CharacterForm, but if there are circumstances
+ in which you don't want to let players edit all the same attributes they had
+ access to during creation, you can redefine this form with those fields you do
+ wish to allow.
+
+ """
+
+ pass
[docs]deftest_get(self):
+ # Try accessing page while not logged in
+ response=self.client.get(reverse(self.url_name,kwargs=self.get_kwargs()))
+ self.assertEqual(response.status_code,self.unauthenticated_response)
[docs]@override_settings(MULTISESSION_MODE=0)
+ deftest_valid_access_multisession_0(self):
+ "Account1 with no characters should be able to create a new one"
+ self.account.db._playable_characters=[]
+
+ # Login account
+ self.login()
+
+ # Post data for a new character
+ data={"db_key":"gannon","desc":"Some dude."}
+
+ response=self.client.post(reverse(self.url_name),data=data,follow=True)
+ self.assertEqual(response.status_code,200)
+
+ # Make sure the character was actually created
+ self.assertTrue(
+ len(self.account.db._playable_characters)==1,
+ "Account only has the following characters attributed to it: %s"
+ %self.account.db._playable_characters,
+ )
+
+
[docs]@override_settings(MULTISESSION_MODE=2)
+ @override_settings(MAX_NR_CHARACTERS=10)
+ deftest_valid_access_multisession_2(self):
+ "Account1 should be able to create a new character"
+ # Login account
+ self.login()
+
+ # Post data for a new character
+ data={"db_key":"gannon","desc":"Some dude."}
+
+ response=self.client.post(reverse(self.url_name),data=data,follow=True)
+ self.assertEqual(response.status_code,200)
+
+ # Make sure the character was actually created
+ self.assertTrue(
+ len(self.account.db._playable_characters)>1,
+ "Account only has the following characters attributed to it: %s"
+ %self.account.db._playable_characters,
+ )
[docs]deftest_invalid_access(self):
+ "Account1 should not be able to puppet Account2:Char2"
+ # Login account
+ self.login()
+
+ # Try to access puppet page for char2
+ kwargs={"pk":self.char2.pk,"slug":slugify(self.char2.name)}
+ response=self.client.get(reverse(self.url_name,kwargs=kwargs),follow=True)
+ self.assertTrue(
+ response.status_code>=400,
+ "Invalid access should return a 4xx code-- either obj not found or permission denied! (Returned %s)"
+ %response.status_code,
+ )
[docs]deftest_valid_access(self):
+ "Account1 should be able to update Account1:Char1"
+ # Login account
+ self.login()
+
+ # Try to access update page for char1
+ response=self.client.get(reverse(self.url_name,kwargs=self.get_kwargs()),follow=True)
+ self.assertEqual(response.status_code,200)
+
+ # Try to update char1 desc
+ data={"db_key":self.char1.db_key,"desc":"Just a regular type of dude."}
+ response=self.client.post(
+ reverse(self.url_name,kwargs=self.get_kwargs()),data=data,follow=True
+ )
+ self.assertEqual(response.status_code,200)
+
+ # Make sure the change was made successfully
+ self.assertEqual(self.char1.db.desc,data["desc"])
+
+
[docs]deftest_invalid_access(self):
+ "Account1 should not be able to update Account2:Char2"
+ # Login account
+ self.login()
+
+ # Try to access update page for char2
+ kwargs={"pk":self.char2.pk,"slug":slugify(self.char2.name)}
+ response=self.client.get(reverse(self.url_name,kwargs=kwargs),follow=True)
+ self.assertEqual(response.status_code,403)
[docs]deftest_valid_access(self):
+ "Account1 should be able to delete Account1:Char1"
+ # Login account
+ self.login()
+
+ # Try to access delete page for char1
+ response=self.client.get(reverse(self.url_name,kwargs=self.get_kwargs()),follow=True)
+ self.assertEqual(response.status_code,200)
+
+ # Proceed with deleting it
+ data={"value":"yes"}
+ response=self.client.post(
+ reverse(self.url_name,kwargs=self.get_kwargs()),data=data,follow=True
+ )
+ self.assertEqual(response.status_code,200)
+
+ # Make sure it deleted
+ self.assertFalse(
+ self.char1inself.account.db._playable_characters,
+ "Char1 is still in Account playable characters list.",
+ )
+
+
[docs]deftest_invalid_access(self):
+ "Account1 should not be able to delete Account2:Char2"
+ # Login account
+ self.login()
+
+ # Try to access delete page for char2
+ kwargs={"pk":self.char2.pk,"slug":slugify(self.char2.name)}
+ response=self.client.get(reverse(self.url_name,kwargs=kwargs),follow=True)
+ self.assertEqual(response.status_code,403)
+"""
+This file contains the generic, assorted views that don't fall under one of the other applications.
+Views are django's way of processing e.g. html templates on the fly.
+
+"""
+
+fromcollectionsimportOrderedDict
+
+fromdjango.contrib.admin.sitesimportsite
+fromdjango.confimportsettings
+fromdjango.contribimportmessages
+fromdjango.contrib.auth.mixinsimportLoginRequiredMixin
+fromdjango.contrib.admin.views.decoratorsimportstaff_member_required
+fromdjango.core.exceptionsimportPermissionDenied
+fromdjango.db.models.functionsimportLower
+fromdjango.httpimportHttpResponseBadRequest,HttpResponseRedirect
+fromdjango.shortcutsimportrender
+fromdjango.urlsimportreverse_lazy
+fromdjango.views.genericimportTemplateView,ListView,DetailView
+fromdjango.views.generic.baseimportRedirectView
+fromdjango.views.generic.editimportCreateView,UpdateView,DeleteView
+
+fromevenniaimportSESSION_HANDLER
+fromevennia.help.modelsimportHelpEntry
+fromevennia.objects.modelsimportObjectDB
+fromevennia.accounts.modelsimportAccountDB
+fromevennia.utilsimportclass_from_module
+fromevennia.utils.loggerimporttail_log_file
+fromevennia.web.websiteimportformsaswebsite_forms
+
+fromdjango.utils.textimportslugify
+
+_BASE_CHAR_TYPECLASS=settings.BASE_CHARACTER_TYPECLASS
+
+# typeclass fallbacks
+
+def_gamestats():
+ # Some misc. configurable stuff.
+ # TODO: Move this to either SQL or settings.py based configuration.
+ fpage_account_limit=4
+
+ # A QuerySet of the most recently connected accounts.
+ recent_users=AccountDB.objects.get_recently_connected_accounts()[:fpage_account_limit]
+ nplyrs_conn_recent=len(recent_users)or"none"
+ nplyrs=AccountDB.objects.num_total_accounts()or"none"
+ nplyrs_reg_recent=len(AccountDB.objects.get_recently_created_accounts())or"none"
+ nsess=SESSION_HANDLER.account_count()
+ # nsess = len(AccountDB.objects.get_connected_accounts()) or "no one"
+
+ nobjs=ObjectDB.objects.count()
+ nobjs=nobjsor1# fix zero-div error with empty database
+ Character=class_from_module(settings.BASE_CHARACTER_TYPECLASS,
+ fallback=settings.FALLBACK_CHARACTER_TYPECLASS)
+ nchars=Character.objects.all_family().count()
+ Room=class_from_module(settings.BASE_ROOM_TYPECLASS,
+ fallback=settings.FALLBACK_ROOM_TYPECLASS)
+ nrooms=Room.objects.all_family().count()
+ Exit=class_from_module(settings.BASE_EXIT_TYPECLASS,
+ fallback=settings.FALLBACK_EXIT_TYPECLASS)
+ nexits=Exit.objects.all_family().count()
+ nothers=nobjs-nchars-nrooms-nexits
+
+ pagevars={
+ "page_title":"Front Page",
+ "accounts_connected_recent":recent_users,
+ "num_accounts_connected":nsessor"no one",
+ "num_accounts_registered":nplyrsor"no",
+ "num_accounts_connected_recent":nplyrs_conn_recentor"no",
+ "num_accounts_registered_recent":nplyrs_reg_recentor"no one",
+ "num_rooms":nroomsor"none",
+ "num_exits":nexitsor"no",
+ "num_objects":nobjsor"none",
+ "num_characters":ncharsor"no",
+ "num_others":nothersor"no",
+ }
+ returnpagevars
+
+
+
[docs]defto_be_implemented(request):
+ """
+ A notice letting the user know that this particular feature hasn't been
+ implemented yet.
+ """
+
+ pagevars={"page_title":"To Be Implemented..."}
+
+ returnrender(request,"tbi.html",pagevars)
[docs]defadmin_wrapper(request):
+ """
+ Wrapper that allows us to properly use the base Django admin site, if needed.
+ """
+ returnstaff_member_required(site.index)(request)
+
+
+#
+# Class-based views
+#
+
+
+
[docs]classEvenniaIndexView(TemplateView):
+ """
+ This is a basic example of a Django class-based view, which are functionally
+ very similar to Evennia Commands but differ in structure. Commands are used
+ to interface with users using a terminal client. Views are used to interface
+ with users using a web browser.
+
+ To use a class-based view, you need to have written a template in HTML, and
+ then you write a view like this to tell Django what values to display on it.
+
+ While there are simpler ways of writing views using plain functions (and
+ Evennia currently contains a few examples of them), just like Commands,
+ writing views as classes provides you with more flexibility-- you can extend
+ classes and change things to suit your needs rather than having to copy and
+ paste entire code blocks over and over. Django also comes with many default
+ views for displaying things, all of them implemented as classes.
+
+ This particular example displays the index page.
+
+ """
+
+ # Tell the view what HTML template to use for the page
+ template_name="website/index.html"
+
+ # This method tells the view what data should be displayed on the template.
+
[docs]defget_context_data(self,**kwargs):
+ """
+ This is a common Django method. Think of this as the website
+ equivalent of the Evennia Command.func() method.
+
+ If you just want to display a static page with no customization, you
+ don't need to define this method-- just create a view, define
+ template_name and you're done.
+
+ The only catch here is that if you extend or overwrite this method,
+ you'll always want to make sure you call the parent method to get a
+ context object. It's just a dict, but it comes prepopulated with all
+ sorts of background data intended for display on the page.
+
+ You can do whatever you want to it, but it must be returned at the end
+ of this method.
+
+ Keyword Args:
+ any (any): Passed through.
+
+ Returns:
+ context (dict): Dictionary of data you want to display on the page.
+
+ """
+ # Always call the base implementation first to get a context object
+ context=super(EvenniaIndexView,self).get_context_data(**kwargs)
+
+ # Add game statistics and other pagevars
+ context.update(_gamestats())
+
+ returncontext
+
+
+
[docs]classTypeclassMixin(object):
+ """
+ This is a "mixin", a modifier of sorts.
+
+ Django views typically work with classes called "models." Evennia objects
+ are an enhancement upon these Django models and are called "typeclasses."
+ But Django itself has no idea what a "typeclass" is.
+
+ For the sake of mitigating confusion, any view class with this in its
+ inheritance list will be modified to work with Evennia Typeclass objects or
+ Django models interchangeably.
+
+ """
+
+ @property
+ deftypeclass(self):
+ returnself.model
+
+ @typeclass.setter
+ deftypeclass(self,value):
+ self.model=value
+
+
+
[docs]classEvenniaCreateView(CreateView,TypeclassMixin):
+ """
+ This view extends Django's default CreateView.
+
+ CreateView is used for creating new objects, be they Accounts, Characters or
+ otherwise.
+
+ """
+
+ @property
+ defpage_title(self):
+ # Makes sure the page has a sensible title.
+ return"Create %s"%self.typeclass._meta.verbose_name.title()
+
+
+
[docs]classEvenniaDetailView(DetailView,TypeclassMixin):
+ """
+ This view extends Django's default DetailView.
+
+ DetailView is used for displaying objects, be they Accounts, Characters or
+ otherwise.
+
+ """
+
+ @property
+ defpage_title(self):
+ # Makes sure the page has a sensible title.
+ return"%s Detail"%self.typeclass._meta.verbose_name.title()
+
+
+
[docs]classEvenniaUpdateView(UpdateView,TypeclassMixin):
+ """
+ This view extends Django's default UpdateView.
+
+ UpdateView is used for updating objects, be they Accounts, Characters or
+ otherwise.
+
+ """
+
+ @property
+ defpage_title(self):
+ # Makes sure the page has a sensible title.
+ return"Update %s"%self.typeclass._meta.verbose_name.title()
+
+
+
[docs]classEvenniaDeleteView(DeleteView,TypeclassMixin):
+ """
+ This view extends Django's default DeleteView.
+
+ DeleteView is used for deleting objects, be they Accounts, Characters or
+ otherwise.
+
+ """
+
+ @property
+ defpage_title(self):
+ # Makes sure the page has a sensible title.
+ return"Delete %s"%self.typeclass._meta.verbose_name.title()
+
+
+#
+# Object views
+#
+
+
+
[docs]classObjectDetailView(EvenniaDetailView):
+ """
+ This is an important view.
+
+ Any view you write that deals with displaying, updating or deleting a
+ specific object will want to inherit from this. It provides the mechanisms
+ by which to retrieve the object and make sure the user requesting it has
+ permissions to actually *do* things to it.
+
+ """
+
+ # -- Django constructs --
+ #
+ # Choose what class of object this view will display. Note that this should
+ # be an actual Python class (i.e. do `from typeclasses.characters import
+ # Character`, then put `Character`), not an Evennia typeclass path
+ # (i.e. `typeclasses.characters.Character`).
+ #
+ # So when you extend it, this line should look simple, like:
+ # model = Object
+ model=class_from_module(settings.BASE_OBJECT_TYPECLASS,
+ fallback=settings.FALLBACK_OBJECT_TYPECLASS)
+
+ # What HTML template you wish to use to display this page.
+ template_name="website/object_detail.html"
+
+ # -- Evennia constructs --
+ #
+ # What lock type to check for the requesting user, authenticated or not.
+ # https://github.com/evennia/evennia/wiki/Locks#valid-access_types
+ access_type="view"
+
+ # What attributes of the object you wish to display on the page. Model-level
+ # attributes will take precedence over identically-named db.attributes!
+ # The order you specify here will be followed.
+ attributes=["name","desc"]
+
+
[docs]defget_context_data(self,**kwargs):
+ """
+ Adds an 'attributes' list to the request context consisting of the
+ attributes specified at the class level, and in the order provided.
+
+ Django views do not provide a way to reference dynamic attributes, so
+ we have to grab them all before we render the template.
+
+ Returns:
+ context (dict): Django context object
+
+ """
+ # Get the base Django context object
+ context=super(ObjectDetailView,self).get_context_data(**kwargs)
+
+ # Get the object in question
+ obj=self.get_object()
+
+ # Create an ordered dictionary to contain the attribute map
+ attribute_list=OrderedDict()
+
+ forattributeinself.attributes:
+ # Check if the attribute is a core fieldname (name, desc)
+ ifattributeinself.typeclass._meta._property_names:
+ attribute_list[attribute.title()]=getattr(obj,attribute,"")
+
+ # Check if the attribute is a db attribute (char1.db.favorite_color)
+ else:
+ attribute_list[attribute.title()]=getattr(obj.db,attribute,"")
+
+ # Add our attribute map to the Django request context, so it gets
+ # displayed on the template
+ context["attribute_list"]=attribute_list
+
+ # Return the comprehensive context object
+ returncontext
+
+
[docs]defget_object(self,queryset=None):
+ """
+ Override of Django hook that provides some important Evennia-specific
+ functionality.
+
+ Evennia does not natively store slugs, so where a slug is provided,
+ calculate the same for the object and make sure it matches.
+
+ This also checks to make sure the user has access to view/edit/delete
+ this object!
+
+ """
+ # A queryset can be provided to pre-emptively limit what objects can
+ # possibly be returned. For example, you can supply a queryset that
+ # only returns objects whose name begins with "a".
+ ifnotqueryset:
+ queryset=self.get_queryset()
+
+ # Get the object, ignoring all checks and filters for now
+ obj=self.typeclass.objects.get(pk=self.kwargs.get("pk"))
+
+ # Check if this object was requested in a valid manner
+ ifslugify(obj.name)!=self.kwargs.get(self.slug_url_kwarg):
+ raiseHttpResponseBadRequest(
+ "No %(verbose_name)s found matching the query"
+ %{"verbose_name":queryset.model._meta.verbose_name}
+ )
+
+ # Check if the requestor account has permissions to access object
+ account=self.request.user
+ ifnotobj.access(account,self.access_type):
+ raisePermissionDenied("You are not authorized to %s this object."%self.access_type)
+
+ # Get the object, if it is in the specified queryset
+ obj=super(ObjectDetailView,self).get_object(queryset)
+
+ returnobj
+
+
+
[docs]classObjectCreateView(LoginRequiredMixin,EvenniaCreateView):
+ """
+ This is an important view.
+
+ Any view you write that deals with creating a specific object will want to
+ inherit from this. It provides the mechanisms by which to make sure the user
+ requesting creation of an object is authenticated, and provides a sane
+ default title for the page.
+
+ """
+
+ model=class_from_module(settings.BASE_OBJECT_TYPECLASS,
+ fallback=settings.FALLBACK_OBJECT_TYPECLASS)
+
+
+
[docs]classObjectDeleteView(LoginRequiredMixin,ObjectDetailView,EvenniaDeleteView):
+ """
+ This is an important view for obvious reasons!
+
+ Any view you write that deals with deleting a specific object will want to
+ inherit from this. It provides the mechanisms by which to make sure the user
+ requesting deletion of an object is authenticated, and that they have
+ permissions to delete the requested object.
+
+ """
+
+ # -- Django constructs --
+ model=class_from_module(settings.BASE_OBJECT_TYPECLASS,
+ fallback=settings.FALLBACK_OBJECT_TYPECLASS)
+ template_name="website/object_confirm_delete.html"
+
+ # -- Evennia constructs --
+ access_type="delete"
+
+
[docs]defdelete(self,request,*args,**kwargs):
+ """
+ Calls the delete() method on the fetched object and then
+ redirects to the success URL.
+
+ We extend this so we can capture the name for the sake of confirmation.
+
+ """
+ # Get the object in question. ObjectDetailView.get_object() will also
+ # check to make sure the current user (authenticated or not) has
+ # permission to delete it!
+ obj=str(self.get_object())
+
+ # Perform the actual deletion (the parent class handles this, which will
+ # in turn call the delete() method on the object)
+ response=super(ObjectDeleteView,self).delete(request,*args,**kwargs)
+
+ # Notify the user of the deletion
+ messages.success(request,"Successfully deleted '%s'."%obj)
+ returnresponse
+
+
+
[docs]classObjectUpdateView(LoginRequiredMixin,ObjectDetailView,EvenniaUpdateView):
+ """
+ This is an important view.
+
+ Any view you write that deals with updating a specific object will want to
+ inherit from this. It provides the mechanisms by which to make sure the user
+ requesting editing of an object is authenticated, and that they have
+ permissions to edit the requested object.
+
+ This functions slightly different from default Django UpdateViews in that
+ it does not update core model fields, *only* object attributes!
+
+ """
+
+ # -- Django constructs --
+ model=class_from_module(settings.BASE_OBJECT_TYPECLASS,
+ fallback=settings.FALLBACK_OBJECT_TYPECLASS)
+
+ # -- Evennia constructs --
+ access_type="edit"
+
+
[docs]defget_success_url(self):
+ """
+ Django hook.
+
+ Can be overridden to return any URL you want to redirect the user to
+ after the object is successfully updated, but by default it goes to the
+ object detail page so the user can see their changes reflected.
+
+ """
+ ifself.success_url:
+ returnself.success_url
+ returnself.object.web_get_detail_url()
+
+
[docs]defget_initial(self):
+ """
+ Django hook, modified for Evennia.
+
+ Prepopulates the update form field values based on object db attributes.
+
+ Returns:
+ data (dict): Dictionary of key:value pairs containing initial form
+ data.
+
+ """
+ # Get the object we want to update
+ obj=self.get_object()
+
+ # Get attributes
+ data={k:getattr(obj.db,k,"")forkinself.form_class.base_fields}
+
+ # Get model fields
+ data.update({k:getattr(obj,k,"")forkinself.form_class.Meta.fields})
+
+ returndata
+
+
[docs]defform_valid(self,form):
+ """
+ Override of Django hook.
+
+ Updates object attributes based on values submitted.
+
+ This is run when the form is submitted and the data on it is deemed
+ valid-- all values are within expected ranges, all strings contain
+ valid characters and lengths, etc.
+
+ This method is only called if all values for the fields submitted
+ passed form validation, so at this point we can assume the data is
+ validated and sanitized.
+
+ """
+ # Get the attributes after they've been cleaned and validated
+ data={k:vfork,vinform.cleaned_data.items()ifknotinself.form_class.Meta.fields}
+
+ # Update the object attributes
+ forkey,valueindata.items():
+ self.object.attributes.add(key,value)
+ messages.success(self.request,"Successfully updated '%s' for %s."%(key,self.object))
+
+ # Do not return super().form_valid; we don't want to update the model
+ # instance, just its attributes.
+ returnHttpResponseRedirect(self.get_success_url())
+
+
+#
+# Account views
+#
+
+
+
[docs]classAccountMixin(TypeclassMixin):
+ """
+ This is a "mixin", a modifier of sorts.
+
+ Any view class with this in its inheritance list will be modified to work
+ with Account objects instead of generic Objects or otherwise.
+
+ """
+
+ # -- Django constructs --
+ model=class_from_module(settings.BASE_ACCOUNT_TYPECLASS,
+ fallback=settings.FALLBACK_ACCOUNT_TYPECLASS)
+ form_class=website_forms.AccountForm
[docs]defform_valid(self,form):
+ """
+ Django hook, modified for Evennia.
+
+ This hook is called after a valid form is submitted.
+
+ When an account creation form is submitted and the data is deemed valid,
+ proceeds with creating the Account object.
+
+ """
+ # Get values provided
+ username=form.cleaned_data["username"]
+ password=form.cleaned_data["password1"]
+ email=form.cleaned_data.get("email","")
+
+ # Create account
+ account,errs=self.typeclass.create(username=username,password=password,email=email)
+
+ # If unsuccessful, display error messages to user
+ ifnotaccount:
+ [messages.error(self.request,err)forerrinerrs]
+
+ # Call the Django "form failure" hook
+ returnself.form_invalid(form)
+
+ # Inform user of success
+ messages.success(
+ self.request,
+ "Your account '%s' was successfully created! "
+ "You may log in using it now."%account.name,
+ )
+
+ # Redirect the user to the login page
+ returnHttpResponseRedirect(self.success_url)
+
+
+#
+# Character views
+#
+
+
+
[docs]classCharacterMixin(TypeclassMixin):
+ """
+ This is a "mixin", a modifier of sorts.
+
+ Any view class with this in its inheritance list will be modified to work
+ with Character objects instead of generic Objects or otherwise.
+
+ """
+
+ # -- Django constructs --
+ model=class_from_module(settings.BASE_CHARACTER_TYPECLASS,
+ fallback=settings.FALLBACK_CHARACTER_TYPECLASS)
+ form_class=website_forms.CharacterForm
+ success_url=reverse_lazy("character-manage")
+
+
[docs]defget_queryset(self):
+ """
+ This method will override the Django get_queryset method to only
+ return a list of characters associated with the current authenticated
+ user.
+
+ Returns:
+ queryset (QuerySet): Django queryset for use in the given view.
+
+ """
+ # Get IDs of characters owned by account
+ account=self.request.user
+ ids=[getattr(x,"id")forxinaccount.charactersifx]
+
+ # Return a queryset consisting of those characters
+ returnself.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key"))
+
+
+
[docs]classCharacterListView(LoginRequiredMixin,CharacterMixin,ListView):
+ """
+ This view provides a mechanism by which a logged-in player can view a list
+ of all other characters.
+
+ This view requires authentication by default as a nominal effort to prevent
+ human stalkers and automated bots/scrapers from harvesting data on your users.
+
+ """
+
+ # -- Django constructs --
+ template_name="website/character_list.html"
+ paginate_by=100
+
+ # -- Evennia constructs --
+ page_title="Character List"
+ access_type="view"
+
+
[docs]defget_queryset(self):
+ """
+ This method will override the Django get_queryset method to return a
+ list of all characters (filtered/sorted) instead of just those limited
+ to the account.
+
+ Returns:
+ queryset (QuerySet): Django queryset for use in the given view.
+
+ """
+ account=self.request.user
+
+ # Return a queryset consisting of characters the user is allowed to
+ # see.
+ ids=[
+ obj.idforobjinself.typeclass.objects.all()ifobj.access(account,self.access_type)
+ ]
+
+ returnself.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key"))
+
+
+
[docs]classCharacterPuppetView(LoginRequiredMixin,CharacterMixin,RedirectView,ObjectDetailView):
+ """
+ This view provides a mechanism by which a logged-in player can "puppet" one
+ of their characters within the context of the website.
+
+ It also ensures that any user attempting to puppet something is logged in,
+ and that their intended puppet is one that they own.
+
+ """
+
+
[docs]defget_redirect_url(self,*args,**kwargs):
+ """
+ Django hook.
+
+ This view returns the URL to which the user should be redirected after
+ a passed or failed puppet attempt.
+
+ Returns:
+ url (str): Path to post-puppet destination.
+
+ """
+ # Get the requested character, if it belongs to the authenticated user
+ char=self.get_object()
+
+ # Get the page the user came from
+ next_page=self.request.GET.get("next",self.success_url)
+
+ ifchar:
+ # If the account owns the char, store the ID of the char in the
+ # Django request's session (different from Evennia session!).
+ # We do this because characters don't serialize well.
+ self.request.session["puppet"]=int(char.pk)
+ messages.success(self.request,"You become '%s'!"%char)
+ else:
+ # If the puppeting failed, clear out the cached puppet value
+ self.request.session["puppet"]=None
+ messages.error(self.request,"You cannot become '%s'."%char)
+
+ returnnext_page
+
+
+
[docs]classCharacterManageView(LoginRequiredMixin,CharacterMixin,ListView):
+ """
+ This view provides a mechanism by which a logged-in player can browse,
+ edit, or delete their own characters.
+
+ """
+
+ # -- Django constructs --
+ paginate_by=10
+ template_name="website/character_manage_list.html"
+
+ # -- Evennia constructs --
+ page_title="Manage Characters"
+
+
+
[docs]classCharacterUpdateView(CharacterMixin,ObjectUpdateView):
+ """
+ This view provides a mechanism by which a logged-in player (enforced by
+ ObjectUpdateView) can edit the attributes of a character they own.
+
+ """
+
+ # -- Django constructs --
+ form_class=website_forms.CharacterUpdateForm
+ template_name="website/character_form.html"
+
+
+
[docs]classCharacterDetailView(CharacterMixin,ObjectDetailView):
+ """
+ This view provides a mechanism by which a user can view the attributes of
+ a character, owned by them or not.
+
+ """
+
+ # -- Django constructs --
+ template_name="website/object_detail.html"
+
+ # -- Evennia constructs --
+ # What attributes to display for this object
+ attributes=["name","desc"]
+ access_type="view"
+
+
[docs]defget_queryset(self):
+ """
+ This method will override the Django get_queryset method to return a
+ list of all characters the user may access.
+
+ Returns:
+ queryset (QuerySet): Django queryset for use in the given view.
+
+ """
+ account=self.request.user
+
+ # Return a queryset consisting of characters the user is allowed to
+ # see.
+ ids=[
+ obj.idforobjinself.typeclass.objects.all()ifobj.access(account,self.access_type)
+ ]
+
+ returnself.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key"))
+
+
+
[docs]classCharacterDeleteView(CharacterMixin,ObjectDeleteView):
+ """
+ This view provides a mechanism by which a logged-in player (enforced by
+ ObjectDeleteView) can delete a character they own.
+
+ """
+
+ pass
+
+
+
[docs]classCharacterCreateView(CharacterMixin,ObjectCreateView):
+ """
+ This view provides a mechanism by which a logged-in player (enforced by
+ ObjectCreateView) can create a new character.
+
+ """
+
+ # -- Django constructs --
+ template_name="website/character_form.html"
+
+
[docs]defform_valid(self,form):
+ """
+ Django hook, modified for Evennia.
+
+ This hook is called after a valid form is submitted.
+
+ When an character creation form is submitted and the data is deemed valid,
+ proceeds with creating the Character object.
+
+ """
+ # Get account object creating the character
+ account=self.request.user
+ character=None
+
+ # Get attributes from the form
+ self.attributes={k:form.cleaned_data[k]forkinform.cleaned_data.keys()}
+ charname=self.attributes.pop("db_key")
+ description=self.attributes.pop("desc")
+ # Create a character
+ character,errors=self.typeclass.create(charname,account,description=description)
+
+ iferrors:
+ # Echo error messages to the user
+ [messages.error(self.request,x)forxinerrors]
+
+ ifcharacter:
+ # Assign attributes from form
+ forkey,valueinself.attributes.items():
+ setattr(character.db,key,value)
+
+ # Return the user to the character management page, unless overridden
+ messages.success(self.request,"Your character '%s' was created!"%character.name)
+ returnHttpResponseRedirect(self.success_url)
+
+ else:
+ # Call the Django "form failed" hook
+ messages.error(self.request,"Your character could not be created.")
+ returnself.form_invalid(form)
+
+
+#
+# Channel views
+#
+
+
+
[docs]classChannelMixin(TypeclassMixin):
+ """
+ This is a "mixin", a modifier of sorts.
+
+ Any view class with this in its inheritance list will be modified to work
+ with HelpEntry objects instead of generic Objects or otherwise.
+
+ """
+
+ # -- Django constructs --
+ model=class_from_module(settings.BASE_CHANNEL_TYPECLASS,
+ fallback=settings.FALLBACK_CHANNEL_TYPECLASS)
+
+ # -- Evennia constructs --
+ page_title="Channels"
+
+ # What lock type to check for the requesting user, authenticated or not.
+ # https://github.com/evennia/evennia/wiki/Locks#valid-access_types
+ access_type="listen"
+
+
[docs]defget_queryset(self):
+ """
+ Django hook; here we want to return a list of only those Channels
+ and other documentation that the current user is allowed to see.
+
+ Returns:
+ queryset (QuerySet): List of Channels available to the user.
+
+ """
+ account=self.request.user
+
+ # Get list of all Channels
+ channels=self.typeclass.objects.all().iterator()
+
+ # Now figure out which ones the current user is allowed to see
+ bucket=[channel.idforchannelinchannelsifchannel.access(account,"listen")]
+
+ # Re-query and set a sorted list
+ filtered=self.typeclass.objects.filter(id__in=bucket).order_by(Lower("db_key"))
+
+ returnfiltered
+
+
+
[docs]classChannelListView(ChannelMixin,ListView):
+ """
+ Returns a list of channels that can be viewed by a user, authenticated
+ or not.
+
+ """
+
+ # -- Django constructs --
+ paginate_by=100
+ template_name="website/channel_list.html"
+
+ # -- Evennia constructs --
+ page_title="Channel Index"
+
+ max_popular=10
+
+
[docs]defget_context_data(self,**kwargs):
+ """
+ Django hook; we override it to calculate the most popular channels.
+
+ Returns:
+ context (dict): Django context object
+
+ """
+ context=super(ChannelListView,self).get_context_data(**kwargs)
+
+ # Calculate which channels are most popular
+ context["most_popular"]=sorted(
+ list(self.get_queryset()),
+ key=lambdachannel:len(channel.subscriptions.all()),
+ reverse=True,
+ )[:self.max_popular]
+
+ returncontext
+
+
+
[docs]classChannelDetailView(ChannelMixin,ObjectDetailView):
+ """
+ Returns the log entries for a given channel.
+
+ """
+
+ # -- Django constructs --
+ template_name="website/channel_detail.html"
+
+ # -- Evennia constructs --
+ # What attributes of the object you wish to display on the page. Model-level
+ # attributes will take precedence over identically-named db.attributes!
+ # The order you specify here will be followed.
+ attributes=["name"]
+
+ # How many log entries to read and display.
+ max_num_lines=1000
+
+
[docs]defget_context_data(self,**kwargs):
+ """
+ Django hook; before we can display the channel logs, we need to recall
+ the logfile and read its lines.
+
+ Returns:
+ context (dict): Django context object
+
+ """
+ # Get the parent context object, necessary first step
+ context=super(ChannelDetailView,self).get_context_data(**kwargs)
+
+ # Get the filename this Channel is recording to
+ filename=self.object.attributes.get(
+ "log_file",default="channel_%s.log"%self.object.key
+ )
+
+ # Split log entries so we can filter by time
+ bucket=[]
+ forlogin(x.strip()forxintail_log_file(filename,0,self.max_num_lines)):
+ ifnotlog:
+ continue
+ try:
+ time,msg=log.split(" [-] ")
+ time_key=time.split(":")[0]
+ exceptValueError:
+ # malformed log line - skip line
+ continue
+ bucket.append({"key":time_key,"timestamp":time,"message":msg})
+
+ # Add the processed entries to the context
+ context["object_list"]=bucket
+
+ # Get a list of unique timestamps by hour and sort them
+ context["object_filters"]=sorted(set([x["key"]forxinbucket]))
+
+ returncontext
+
+
[docs]defget_object(self,queryset=None):
+ """
+ Override of Django hook that retrieves an object by slugified channel
+ name.
+
+ Returns:
+ channel (Channel): Channel requested in the URL.
+
+ """
+ # Get the queryset for the help entries the user can access
+ ifnotqueryset:
+ queryset=self.get_queryset()
+
+ # Find the object in the queryset
+ channel=slugify(self.kwargs.get("slug",""))
+ obj=next((xforxinquerysetifslugify(x.db_key)==channel),None)
+
+ # Check if this object was requested in a valid manner
+ ifnotobj:
+ raiseHttpResponseBadRequest(
+ "No %(verbose_name)s found matching the query"
+ %{"verbose_name":queryset.model._meta.verbose_name}
+ )
+
+ returnobj
+
+
+#
+# Help views
+#
+
+
+
[docs]classHelpMixin(TypeclassMixin):
+ """
+ This is a "mixin", a modifier of sorts.
+
+ Any view class with this in its inheritance list will be modified to work
+ with HelpEntry objects instead of generic Objects or otherwise.
+
+ """
+
+ # -- Django constructs --
+ model=HelpEntry
+
+ # -- Evennia constructs --
+ page_title="Help"
+
+
[docs]defget_queryset(self):
+ """
+ Django hook; here we want to return a list of only those HelpEntries
+ and other documentation that the current user is allowed to see.
+
+ Returns:
+ queryset (QuerySet): List of Help entries available to the user.
+
+ """
+ account=self.request.user
+
+ # Get list of all HelpEntries
+ entries=self.typeclass.objects.all().iterator()
+
+ # Now figure out which ones the current user is allowed to see
+ bucket=[entry.idforentryinentriesifentry.access(account,"view")]
+
+ # Re-query and set a sorted list
+ filtered=(
+ self.typeclass.objects.filter(id__in=bucket)
+ .order_by(Lower("db_key"))
+ .order_by(Lower("db_help_category"))
+ )
+
+ returnfiltered
+
+
+
[docs]classHelpListView(HelpMixin,ListView):
+ """
+ Returns a list of help entries that can be viewed by a user, authenticated
+ or not.
+
+ """
+
+ # -- Django constructs --
+ paginate_by=500
+ template_name="website/help_list.html"
+
+ # -- Evennia constructs --
+ page_title="Help Index"
+
+
+
[docs]classHelpDetailView(HelpMixin,EvenniaDetailView):
+ """
+ Returns the detail page for a given help entry.
+
+ """
+
+ # -- Django constructs --
+ template_name="website/help_detail.html"
+
+
[docs]defget_context_data(self,**kwargs):
+ """
+ Adds navigational data to the template to let browsers go to the next
+ or previous entry in the help list.
+
+ Returns:
+ context (dict): Django context object
+
+ """
+ context=super(HelpDetailView,self).get_context_data(**kwargs)
+
+ # Get the object in question
+ obj=self.get_object()
+
+ # Get queryset and filter out non-related categories
+ queryset=(
+ self.get_queryset()
+ .filter(db_help_category=obj.db_help_category)
+ .order_by(Lower("db_key"))
+ )
+ context["topic_list"]=queryset
+
+ # Find the index position of the given obj in the queryset
+ objs=list(queryset)
+ fori,xinenumerate(objs):
+ ifobjisx:
+ break
+
+ # Find the previous and next topics, if either exist
+ try:
+ asserti+1<=len(objs)andobjs[i+1]isnotobj
+ context["topic_next"]=objs[i+1]
+ except:
+ context["topic_next"]=None
+
+ try:
+ asserti-1>=0andobjs[i-1]isnotobj
+ context["topic_previous"]=objs[i-1]
+ except:
+ context["topic_previous"]=None
+
+ # Format the help entry using HTML instead of newlines
+ text=obj.db_entrytext
+ text=text.replace("\r\n\r\n","\n\n")
+ text=text.replace("\r\n","\n")
+ text=text.replace("\n","<br />")
+ context["entry_text"]=text
+
+ returncontext
+
+
[docs]defget_object(self,queryset=None):
+ """
+ Override of Django hook that retrieves an object by category and topic
+ instead of pk and slug.
+
+ Returns:
+ entry (HelpEntry): HelpEntry requested in the URL.
+
+ """
+ # Get the queryset for the help entries the user can access
+ ifnotqueryset:
+ queryset=self.get_queryset()
+
+ # Find the object in the queryset
+ category=slugify(self.kwargs.get("category",""))
+ topic=slugify(self.kwargs.get("topic",""))
+ obj=next(
+ (
+ x
+ forxinqueryset
+ ifslugify(x.db_help_category)==categoryandslugify(x.db_key)==topic
+ ),
+ None,
+ )
+
+ # Check if this object was requested in a valid manner
+ ifnotobj:
+ returnHttpResponseBadRequest(
+ "No %(verbose_name)s found matching the query"
+ %{"verbose_name":queryset.model._meta.verbose_name}
+ )
+
+ returnobj
+"""functools.py - Tools for working with functions and callable objects
+"""
+# Python module wrapper for _functools C module
+# to allow utilities written in Python to be added
+# to the functools module.
+# Written by Nick Coghlan <ncoghlan at gmail.com>,
+# Raymond Hettinger <python at rcn.com>,
+# and Łukasz Langa <lukasz at langa.pl>.
+# Copyright (C) 2006-2013 Python Software Foundation.
+# See C source code for _functools credits/copyright
+
+__all__=['update_wrapper','wraps','WRAPPER_ASSIGNMENTS','WRAPPER_UPDATES',
+ 'total_ordering','cache','cmp_to_key','lru_cache','reduce',
+ 'partial','partialmethod','singledispatch','singledispatchmethod',
+ 'cached_property']
+
+fromabcimportget_cache_token
+fromcollectionsimportnamedtuple
+# import types, weakref # Deferred to single_dispatch()
+fromreprlibimportrecursive_repr
+from_threadimportRLock
+fromtypesimportGenericAlias
+
+
+################################################################################
+### update_wrapper() and wraps() decorator
+################################################################################
+
+# update_wrapper() and wraps() are tools to help write
+# wrapper functions that can handle naive introspection
+
+WRAPPER_ASSIGNMENTS=('__module__','__name__','__qualname__','__doc__',
+ '__annotations__')
+WRAPPER_UPDATES=('__dict__',)
+defupdate_wrapper(wrapper,
+ wrapped,
+ assigned=WRAPPER_ASSIGNMENTS,
+ updated=WRAPPER_UPDATES):
+ """Update a wrapper function to look like the wrapped function
+
+ wrapper is the function to be updated
+ wrapped is the original function
+ assigned is a tuple naming the attributes assigned directly
+ from the wrapped function to the wrapper function (defaults to
+ functools.WRAPPER_ASSIGNMENTS)
+ updated is a tuple naming the attributes of the wrapper that
+ are updated with the corresponding attribute from the wrapped
+ function (defaults to functools.WRAPPER_UPDATES)
+ """
+ forattrinassigned:
+ try:
+ value=getattr(wrapped,attr)
+ exceptAttributeError:
+ pass
+ else:
+ setattr(wrapper,attr,value)
+ forattrinupdated:
+ getattr(wrapper,attr).update(getattr(wrapped,attr,{}))
+ # Issue #17482: set __wrapped__ last so we don't inadvertently copy it
+ # from the wrapped function when updating __dict__
+ wrapper.__wrapped__=wrapped
+ # Return the wrapper so this can be used as a decorator via partial()
+ returnwrapper
+
+defwraps(wrapped,
+ assigned=WRAPPER_ASSIGNMENTS,
+ updated=WRAPPER_UPDATES):
+ """Decorator factory to apply update_wrapper() to a wrapper function
+
+ Returns a decorator that invokes update_wrapper() with the decorated
+ function as the wrapper argument and the arguments to wraps() as the
+ remaining arguments. Default arguments are as for update_wrapper().
+ This is a convenience function to simplify applying partial() to
+ update_wrapper().
+ """
+ returnpartial(update_wrapper,wrapped=wrapped,
+ assigned=assigned,updated=updated)
+
+
+################################################################################
+### total_ordering class decorator
+################################################################################
+
+# The total ordering functions all invoke the root magic method directly
+# rather than using the corresponding operator. This avoids possible
+# infinite recursion that could occur when the operator dispatch logic
+# detects a NotImplemented result and then calls a reflected method.
+
+def_gt_from_lt(self,other,NotImplemented=NotImplemented):
+ 'Return a > b. Computed by @total_ordering from (not a < b) and (a != b).'
+ op_result=type(self).__lt__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnnotop_resultandself!=other
+
+def_le_from_lt(self,other,NotImplemented=NotImplemented):
+ 'Return a <= b. Computed by @total_ordering from (a < b) or (a == b).'
+ op_result=type(self).__lt__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnop_resultorself==other
+
+def_ge_from_lt(self,other,NotImplemented=NotImplemented):
+ 'Return a >= b. Computed by @total_ordering from (not a < b).'
+ op_result=type(self).__lt__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnnotop_result
+
+def_ge_from_le(self,other,NotImplemented=NotImplemented):
+ 'Return a >= b. Computed by @total_ordering from (not a <= b) or (a == b).'
+ op_result=type(self).__le__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnnotop_resultorself==other
+
+def_lt_from_le(self,other,NotImplemented=NotImplemented):
+ 'Return a < b. Computed by @total_ordering from (a <= b) and (a != b).'
+ op_result=type(self).__le__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnop_resultandself!=other
+
+def_gt_from_le(self,other,NotImplemented=NotImplemented):
+ 'Return a > b. Computed by @total_ordering from (not a <= b).'
+ op_result=type(self).__le__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnnotop_result
+
+def_lt_from_gt(self,other,NotImplemented=NotImplemented):
+ 'Return a < b. Computed by @total_ordering from (not a > b) and (a != b).'
+ op_result=type(self).__gt__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnnotop_resultandself!=other
+
+def_ge_from_gt(self,other,NotImplemented=NotImplemented):
+ 'Return a >= b. Computed by @total_ordering from (a > b) or (a == b).'
+ op_result=type(self).__gt__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnop_resultorself==other
+
+def_le_from_gt(self,other,NotImplemented=NotImplemented):
+ 'Return a <= b. Computed by @total_ordering from (not a > b).'
+ op_result=type(self).__gt__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnnotop_result
+
+def_le_from_ge(self,other,NotImplemented=NotImplemented):
+ 'Return a <= b. Computed by @total_ordering from (not a >= b) or (a == b).'
+ op_result=type(self).__ge__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnnotop_resultorself==other
+
+def_gt_from_ge(self,other,NotImplemented=NotImplemented):
+ 'Return a > b. Computed by @total_ordering from (a >= b) and (a != b).'
+ op_result=type(self).__ge__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnop_resultandself!=other
+
+def_lt_from_ge(self,other,NotImplemented=NotImplemented):
+ 'Return a < b. Computed by @total_ordering from (not a >= b).'
+ op_result=type(self).__ge__(self,other)
+ ifop_resultisNotImplemented:
+ returnop_result
+ returnnotop_result
+
+_convert={
+ '__lt__':[('__gt__',_gt_from_lt),
+ ('__le__',_le_from_lt),
+ ('__ge__',_ge_from_lt)],
+ '__le__':[('__ge__',_ge_from_le),
+ ('__lt__',_lt_from_le),
+ ('__gt__',_gt_from_le)],
+ '__gt__':[('__lt__',_lt_from_gt),
+ ('__ge__',_ge_from_gt),
+ ('__le__',_le_from_gt)],
+ '__ge__':[('__le__',_le_from_ge),
+ ('__gt__',_gt_from_ge),
+ ('__lt__',_lt_from_ge)]
+}
+
+deftotal_ordering(cls):
+ """Class decorator that fills in missing ordering methods"""
+ # Find user-defined comparisons (not those inherited from object).
+ roots={opforopin_convertifgetattr(cls,op,None)isnotgetattr(object,op,None)}
+ ifnotroots:
+ raiseValueError('must define at least one ordering operation: < > <= >=')
+ root=max(roots)# prefer __lt__ to __le__ to __gt__ to __ge__
+ foropname,opfuncin_convert[root]:
+ ifopnamenotinroots:
+ opfunc.__name__=opname
+ setattr(cls,opname,opfunc)
+ returncls
+
+
+################################################################################
+### cmp_to_key() function converter
+################################################################################
+
+defcmp_to_key(mycmp):
+ """Convert a cmp= function into a key= function"""
+ classK(object):
+ __slots__=['obj']
+ def__init__(self,obj):
+ self.obj=obj
+ def__lt__(self,other):
+ returnmycmp(self.obj,other.obj)<0
+ def__gt__(self,other):
+ returnmycmp(self.obj,other.obj)>0
+ def__eq__(self,other):
+ returnmycmp(self.obj,other.obj)==0
+ def__le__(self,other):
+ returnmycmp(self.obj,other.obj)<=0
+ def__ge__(self,other):
+ returnmycmp(self.obj,other.obj)>=0
+ __hash__=None
+ returnK
+
+try:
+ from_functoolsimportcmp_to_key
+exceptImportError:
+ pass
+
+
+################################################################################
+### reduce() sequence to a single item
+################################################################################
+
+_initial_missing=object()
+
+defreduce(function,sequence,initial=_initial_missing):
+ """
+ reduce(function, sequence[, initial]) -> value
+
+ Apply a function of two arguments cumulatively to the items of a sequence,
+ from left to right, so as to reduce the sequence to a single value.
+ For example, reduce(lambda x, y: x+y, [1, 2, 3, 4, 5]) calculates
+ ((((1+2)+3)+4)+5). If initial is present, it is placed before the items
+ of the sequence in the calculation, and serves as a default when the
+ sequence is empty.
+ """
+
+ it=iter(sequence)
+
+ ifinitialis_initial_missing:
+ try:
+ value=next(it)
+ exceptStopIteration:
+ raiseTypeError("reduce() of empty sequence with no initial value")fromNone
+ else:
+ value=initial
+
+ forelementinit:
+ value=function(value,element)
+
+ returnvalue
+
+try:
+ from_functoolsimportreduce
+exceptImportError:
+ pass
+
+
+################################################################################
+### partial() argument application
+################################################################################
+
+# Purely functional, no descriptor behaviour
+classpartial:
+ """New function with partial application of the given arguments
+ and keywords.
+ """
+
+ __slots__="func","args","keywords","__dict__","__weakref__"
+
+ def__new__(cls,func,/,*args,**keywords):
+ ifnotcallable(func):
+ raiseTypeError("the first argument must be callable")
+
+ ifhasattr(func,"func"):
+ args=func.args+args
+ keywords={**func.keywords,**keywords}
+ func=func.func
+
+ self=super(partial,cls).__new__(cls)
+
+ self.func=func
+ self.args=args
+ self.keywords=keywords
+ returnself
+
+ def__call__(self,/,*args,**keywords):
+ keywords={**self.keywords,**keywords}
+ returnself.func(*self.args,*args,**keywords)
+
+ @recursive_repr()
+ def__repr__(self):
+ qualname=type(self).__qualname__
+ args=[repr(self.func)]
+ args.extend(repr(x)forxinself.args)
+ args.extend(f"{k}={v!r}"for(k,v)inself.keywords.items())
+ iftype(self).__module__=="functools":
+ returnf"functools.{qualname}({', '.join(args)})"
+ returnf"{qualname}({', '.join(args)})"
+
+ def__reduce__(self):
+ returntype(self),(self.func,),(self.func,self.args,
+ self.keywordsorNone,self.__dict__orNone)
+
+ def__setstate__(self,state):
+ ifnotisinstance(state,tuple):
+ raiseTypeError("argument to __setstate__ must be a tuple")
+ iflen(state)!=4:
+ raiseTypeError(f"expected 4 items in state, got {len(state)}")
+ func,args,kwds,namespace=state
+ if(notcallable(func)ornotisinstance(args,tuple)or
+ (kwdsisnotNoneandnotisinstance(kwds,dict))or
+ (namespaceisnotNoneandnotisinstance(namespace,dict))):
+ raiseTypeError("invalid partial state")
+
+ args=tuple(args)# just in case it's a subclass
+ ifkwdsisNone:
+ kwds={}
+ eliftype(kwds)isnotdict:# XXX does it need to be *exactly* dict?
+ kwds=dict(kwds)
+ ifnamespaceisNone:
+ namespace={}
+
+ self.__dict__=namespace
+ self.func=func
+ self.args=args
+ self.keywords=kwds
+
+try:
+ from_functoolsimportpartial
+exceptImportError:
+ pass
+
+# Descriptor version
+classpartialmethod(object):
+ """Method descriptor with partial application of the given arguments
+ and keywords.
+
+ Supports wrapping existing descriptors and handles non-descriptor
+ callables as instance methods.
+ """
+
+ def__init__(self,func,/,*args,**keywords):
+ ifnotcallable(func)andnothasattr(func,"__get__"):
+ raiseTypeError("{!r} is not callable or a descriptor"
+ .format(func))
+
+ # func could be a descriptor like classmethod which isn't callable,
+ # so we can't inherit from partial (it verifies func is callable)
+ ifisinstance(func,partialmethod):
+ # flattening is mandatory in order to place cls/self before all
+ # other arguments
+ # it's also more efficient since only one function will be called
+ self.func=func.func
+ self.args=func.args+args
+ self.keywords={**func.keywords,**keywords}
+ else:
+ self.func=func
+ self.args=args
+ self.keywords=keywords
+
+ def__repr__(self):
+ args=", ".join(map(repr,self.args))
+ keywords=", ".join("{}={!r}".format(k,v)
+ fork,vinself.keywords.items())
+ format_string="{module}.{cls}({func}, {args}, {keywords})"
+ returnformat_string.format(module=self.__class__.__module__,
+ cls=self.__class__.__qualname__,
+ func=self.func,
+ args=args,
+ keywords=keywords)
+
+ def_make_unbound_method(self):
+ def_method(cls_or_self,/,*args,**keywords):
+ keywords={**self.keywords,**keywords}
+ returnself.func(cls_or_self,*self.args,*args,**keywords)
+ _method.__isabstractmethod__=self.__isabstractmethod__
+ _method._partialmethod=self
+ return_method
+
+ def__get__(self,obj,cls=None):
+ get=getattr(self.func,"__get__",None)
+ result=None
+ ifgetisnotNone:
+ new_func=get(obj,cls)
+ ifnew_funcisnotself.func:
+ # Assume __get__ returning something new indicates the
+ # creation of an appropriate callable
+ result=partial(new_func,*self.args,**self.keywords)
+ try:
+ result.__self__=new_func.__self__
+ exceptAttributeError:
+ pass
+ ifresultisNone:
+ # If the underlying descriptor didn't do anything, treat this
+ # like an instance method
+ result=self._make_unbound_method().__get__(obj,cls)
+ returnresult
+
+ @property
+ def__isabstractmethod__(self):
+ returngetattr(self.func,"__isabstractmethod__",False)
+
+ __class_getitem__=classmethod(GenericAlias)
+
+
+# Helper functions
+
+def_unwrap_partial(func):
+ whileisinstance(func,partial):
+ func=func.func
+ returnfunc
+
+################################################################################
+### LRU Cache function decorator
+################################################################################
+
+_CacheInfo=namedtuple("CacheInfo",["hits","misses","maxsize","currsize"])
+
+class_HashedSeq(list):
+ """ This class guarantees that hash() will be called no more than once
+ per element. This is important because the lru_cache() will hash
+ the key multiple times on a cache miss.
+
+ """
+
+ __slots__='hashvalue'
+
+ def__init__(self,tup,hash=hash):
+ self[:]=tup
+ self.hashvalue=hash(tup)
+
+ def__hash__(self):
+ returnself.hashvalue
+
+def_make_key(args,kwds,typed,
+ kwd_mark=(object(),),
+ fasttypes={int,str},
+ tuple=tuple,type=type,len=len):
+ """Make a cache key from optionally typed positional and keyword arguments
+
+ The key is constructed in a way that is flat as possible rather than
+ as a nested structure that would take more memory.
+
+ If there is only a single argument and its data type is known to cache
+ its hash value, then that argument is returned without a wrapper. This
+ saves space and improves lookup speed.
+
+ """
+ # All of code below relies on kwds preserving the order input by the user.
+ # Formerly, we sorted() the kwds before looping. The new way is *much*
+ # faster; however, it means that f(x=1, y=2) will now be treated as a
+ # distinct call from f(y=2, x=1) which will be cached separately.
+ key=args
+ ifkwds:
+ key+=kwd_mark
+ foriteminkwds.items():
+ key+=item
+ iftyped:
+ key+=tuple(type(v)forvinargs)
+ ifkwds:
+ key+=tuple(type(v)forvinkwds.values())
+ eliflen(key)==1andtype(key[0])infasttypes:
+ returnkey[0]
+ return_HashedSeq(key)
+
+deflru_cache(maxsize=128,typed=False):
+ """Least-recently-used cache decorator.
+
+ If *maxsize* is set to None, the LRU features are disabled and the cache
+ can grow without bound.
+
+ If *typed* is True, arguments of different types will be cached separately.
+ For example, f(3.0) and f(3) will be treated as distinct calls with
+ distinct results.
+
+ Arguments to the cached function must be hashable.
+
+ View the cache statistics named tuple (hits, misses, maxsize, currsize)
+ with f.cache_info(). Clear the cache and statistics with f.cache_clear().
+ Access the underlying function with f.__wrapped__.
+
+ See: https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)
+
+ """
+
+ # Users should only access the lru_cache through its public API:
+ # cache_info, cache_clear, and f.__wrapped__
+ # The internals of the lru_cache are encapsulated for thread safety and
+ # to allow the implementation to change (including a possible C version).
+
+ ifisinstance(maxsize,int):
+ # Negative maxsize is treated as 0
+ ifmaxsize<0:
+ maxsize=0
+ elifcallable(maxsize)andisinstance(typed,bool):
+ # The user_function was passed in directly via the maxsize argument
+ user_function,maxsize=maxsize,128
+ wrapper=_lru_cache_wrapper(user_function,maxsize,typed,_CacheInfo)
+ wrapper.cache_parameters=lambda:{'maxsize':maxsize,'typed':typed}
+ returnupdate_wrapper(wrapper,user_function)
+ elifmaxsizeisnotNone:
+ raiseTypeError(
+ 'Expected first argument to be an integer, a callable, or None')
+
+ defdecorating_function(user_function):
+ wrapper=_lru_cache_wrapper(user_function,maxsize,typed,_CacheInfo)
+ wrapper.cache_parameters=lambda:{'maxsize':maxsize,'typed':typed}
+ returnupdate_wrapper(wrapper,user_function)
+
+ returndecorating_function
+
+def_lru_cache_wrapper(user_function,maxsize,typed,_CacheInfo):
+ # Constants shared by all lru cache instances:
+ sentinel=object()# unique object used to signal cache misses
+ make_key=_make_key# build a key from the function arguments
+ PREV,NEXT,KEY,RESULT=0,1,2,3# names for the link fields
+
+ cache={}
+ hits=misses=0
+ full=False
+ cache_get=cache.get# bound method to lookup a key or return None
+ cache_len=cache.__len__# get cache size without calling len()
+ lock=RLock()# because linkedlist updates aren't threadsafe
+ root=[]# root of the circular doubly linked list
+ root[:]=[root,root,None,None]# initialize by pointing to self
+
+ ifmaxsize==0:
+
+ defwrapper(*args,**kwds):
+ # No caching -- just a statistics update
+ nonlocalmisses
+ misses+=1
+ result=user_function(*args,**kwds)
+ returnresult
+
+ elifmaxsizeisNone:
+
+ defwrapper(*args,**kwds):
+ # Simple caching without ordering or size limit
+ nonlocalhits,misses
+ key=make_key(args,kwds,typed)
+ result=cache_get(key,sentinel)
+ ifresultisnotsentinel:
+ hits+=1
+ returnresult
+ misses+=1
+ result=user_function(*args,**kwds)
+ cache[key]=result
+ returnresult
+
+ else:
+
+ defwrapper(*args,**kwds):
+ # Size limited caching that tracks accesses by recency
+ nonlocalroot,hits,misses,full
+ key=make_key(args,kwds,typed)
+ withlock:
+ link=cache_get(key)
+ iflinkisnotNone:
+ # Move the link to the front of the circular queue
+ link_prev,link_next,_key,result=link
+ link_prev[NEXT]=link_next
+ link_next[PREV]=link_prev
+ last=root[PREV]
+ last[NEXT]=root[PREV]=link
+ link[PREV]=last
+ link[NEXT]=root
+ hits+=1
+ returnresult
+ misses+=1
+ result=user_function(*args,**kwds)
+ withlock:
+ ifkeyincache:
+ # Getting here means that this same key was added to the
+ # cache while the lock was released. Since the link
+ # update is already done, we need only return the
+ # computed result and update the count of misses.
+ pass
+ eliffull:
+ # Use the old root to store the new key and result.
+ oldroot=root
+ oldroot[KEY]=key
+ oldroot[RESULT]=result
+ # Empty the oldest link and make it the new root.
+ # Keep a reference to the old key and old result to
+ # prevent their ref counts from going to zero during the
+ # update. That will prevent potentially arbitrary object
+ # clean-up code (i.e. __del__) from running while we're
+ # still adjusting the links.
+ root=oldroot[NEXT]
+ oldkey=root[KEY]
+ oldresult=root[RESULT]
+ root[KEY]=root[RESULT]=None
+ # Now update the cache dictionary.
+ delcache[oldkey]
+ # Save the potentially reentrant cache[key] assignment
+ # for last, after the root and links have been put in
+ # a consistent state.
+ cache[key]=oldroot
+ else:
+ # Put result in a new link at the front of the queue.
+ last=root[PREV]
+ link=[last,root,key,result]
+ last[NEXT]=root[PREV]=cache[key]=link
+ # Use the cache_len bound method instead of the len() function
+ # which could potentially be wrapped in an lru_cache itself.
+ full=(cache_len()>=maxsize)
+ returnresult
+
+ defcache_info():
+ """Report cache statistics"""
+ withlock:
+ return_CacheInfo(hits,misses,maxsize,cache_len())
+
+ defcache_clear():
+ """Clear the cache and cache statistics"""
+ nonlocalhits,misses,full
+ withlock:
+ cache.clear()
+ root[:]=[root,root,None,None]
+ hits=misses=0
+ full=False
+
+ wrapper.cache_info=cache_info
+ wrapper.cache_clear=cache_clear
+ returnwrapper
+
+try:
+ from_functoolsimport_lru_cache_wrapper
+exceptImportError:
+ pass
+
+
+################################################################################
+### cache -- simplified access to the infinity cache
+################################################################################
+
+defcache(user_function,/):
+ 'Simple lightweight unbounded cache. Sometimes called "memoize".'
+ returnlru_cache(maxsize=None)(user_function)
+
+
+################################################################################
+### singledispatch() - single-dispatch generic function decorator
+################################################################################
+
+def_c3_merge(sequences):
+ """Merges MROs in *sequences* to a single MRO using the C3 algorithm.
+
+ Adapted from https://www.python.org/download/releases/2.3/mro/.
+
+ """
+ result=[]
+ whileTrue:
+ sequences=[sforsinsequencesifs]# purge empty sequences
+ ifnotsequences:
+ returnresult
+ fors1insequences:# find merge candidates among seq heads
+ candidate=s1[0]
+ fors2insequences:
+ ifcandidateins2[1:]:
+ candidate=None
+ break# reject the current head, it appears later
+ else:
+ break
+ ifcandidateisNone:
+ raiseRuntimeError("Inconsistent hierarchy")
+ result.append(candidate)
+ # remove the chosen candidate
+ forseqinsequences:
+ ifseq[0]==candidate:
+ delseq[0]
+
+def_c3_mro(cls,abcs=None):
+ """Computes the method resolution order using extended C3 linearization.
+
+ If no *abcs* are given, the algorithm works exactly like the built-in C3
+ linearization used for method resolution.
+
+ If given, *abcs* is a list of abstract base classes that should be inserted
+ into the resulting MRO. Unrelated ABCs are ignored and don't end up in the
+ result. The algorithm inserts ABCs where their functionality is introduced,
+ i.e. issubclass(cls, abc) returns True for the class itself but returns
+ False for all its direct base classes. Implicit ABCs for a given class
+ (either registered or inferred from the presence of a special method like
+ __len__) are inserted directly after the last ABC explicitly listed in the
+ MRO of said class. If two implicit ABCs end up next to each other in the
+ resulting MRO, their ordering depends on the order of types in *abcs*.
+
+ """
+ fori,baseinenumerate(reversed(cls.__bases__)):
+ ifhasattr(base,'__abstractmethods__'):
+ boundary=len(cls.__bases__)-i
+ break# Bases up to the last explicit ABC are considered first.
+ else:
+ boundary=0
+ abcs=list(abcs)ifabcselse[]
+ explicit_bases=list(cls.__bases__[:boundary])
+ abstract_bases=[]
+ other_bases=list(cls.__bases__[boundary:])
+ forbaseinabcs:
+ ifissubclass(cls,base)andnotany(
+ issubclass(b,base)forbincls.__bases__
+ ):
+ # If *cls* is the class that introduces behaviour described by
+ # an ABC *base*, insert said ABC to its MRO.
+ abstract_bases.append(base)
+ forbaseinabstract_bases:
+ abcs.remove(base)
+ explicit_c3_mros=[_c3_mro(base,abcs=abcs)forbaseinexplicit_bases]
+ abstract_c3_mros=[_c3_mro(base,abcs=abcs)forbaseinabstract_bases]
+ other_c3_mros=[_c3_mro(base,abcs=abcs)forbaseinother_bases]
+ return_c3_merge(
+ [[cls]]+
+ explicit_c3_mros+abstract_c3_mros+other_c3_mros+
+ [explicit_bases]+[abstract_bases]+[other_bases]
+ )
+
+def_compose_mro(cls,types):
+ """Calculates the method resolution order for a given class *cls*.
+
+ Includes relevant abstract base classes (with their respective bases) from
+ the *types* iterable. Uses a modified C3 linearization algorithm.
+
+ """
+ bases=set(cls.__mro__)
+ # Remove entries which are already present in the __mro__ or unrelated.
+ defis_related(typ):
+ return(typnotinbasesandhasattr(typ,'__mro__')
+ andnotisinstance(typ,GenericAlias)
+ andissubclass(cls,typ))
+ types=[nfornintypesifis_related(n)]
+ # Remove entries which are strict bases of other entries (they will end up
+ # in the MRO anyway.
+ defis_strict_base(typ):
+ forotherintypes:
+ iftyp!=otherandtypinother.__mro__:
+ returnTrue
+ returnFalse
+ types=[nfornintypesifnotis_strict_base(n)]
+ # Subclasses of the ABCs in *types* which are also implemented by
+ # *cls* can be used to stabilize ABC ordering.
+ type_set=set(types)
+ mro=[]
+ fortypintypes:
+ found=[]
+ forsubintyp.__subclasses__():
+ ifsubnotinbasesandissubclass(cls,sub):
+ found.append([sforsinsub.__mro__ifsintype_set])
+ ifnotfound:
+ mro.append(typ)
+ continue
+ # Favor subclasses with the biggest number of useful bases
+ found.sort(key=len,reverse=True)
+ forsubinfound:
+ forsubclsinsub:
+ ifsubclsnotinmro:
+ mro.append(subcls)
+ return_c3_mro(cls,abcs=mro)
+
+def_find_impl(cls,registry):
+ """Returns the best matching implementation from *registry* for type *cls*.
+
+ Where there is no registered implementation for a specific type, its method
+ resolution order is used to find a more generic implementation.
+
+ Note: if *registry* does not contain an implementation for the base
+ *object* type, this function may return None.
+
+ """
+ mro=_compose_mro(cls,registry.keys())
+ match=None
+ fortinmro:
+ ifmatchisnotNone:
+ # If *match* is an implicit ABC but there is another unrelated,
+ # equally matching implicit ABC, refuse the temptation to guess.
+ if(tinregistryandtnotincls.__mro__
+ andmatchnotincls.__mro__
+ andnotissubclass(match,t)):
+ raiseRuntimeError("Ambiguous dispatch: {} or {}".format(
+ match,t))
+ break
+ iftinregistry:
+ match=t
+ returnregistry.get(match)
+
+defsingledispatch(func):
+ """Single-dispatch generic function decorator.
+
+ Transforms a function into a generic function, which can have different
+ behaviours depending upon the type of its first argument. The decorated
+ function acts as the default implementation, and additional
+ implementations can be registered using the register() attribute of the
+ generic function.
+ """
+ # There are many programs that use functools without singledispatch, so we
+ # trade-off making singledispatch marginally slower for the benefit of
+ # making start-up of such applications slightly faster.
+ importtypes,weakref
+
+ registry={}
+ dispatch_cache=weakref.WeakKeyDictionary()
+ cache_token=None
+
+ defdispatch(cls):
+ """generic_func.dispatch(cls) -> <function implementation>
+
+ Runs the dispatch algorithm to return the best available implementation
+ for the given *cls* registered on *generic_func*.
+
+ """
+ nonlocalcache_token
+ ifcache_tokenisnotNone:
+ current_token=get_cache_token()
+ ifcache_token!=current_token:
+ dispatch_cache.clear()
+ cache_token=current_token
+ try:
+ impl=dispatch_cache[cls]
+ exceptKeyError:
+ try:
+ impl=registry[cls]
+ exceptKeyError:
+ impl=_find_impl(cls,registry)
+ dispatch_cache[cls]=impl
+ returnimpl
+
+ def_is_valid_dispatch_type(cls):
+ returnisinstance(cls,type)andnotisinstance(cls,GenericAlias)
+
+ defregister(cls,func=None):
+ """generic_func.register(cls, func) -> func
+
+ Registers a new implementation for the given *cls* on a *generic_func*.
+
+ """
+ nonlocalcache_token
+ if_is_valid_dispatch_type(cls):
+ iffuncisNone:
+ returnlambdaf:register(cls,f)
+ else:
+ iffuncisnotNone:
+ raiseTypeError(
+ f"Invalid first argument to `register()`. "
+ f"{cls!r} is not a class."
+ )
+ ann=getattr(cls,'__annotations__',{})
+ ifnotann:
+ raiseTypeError(
+ f"Invalid first argument to `register()`: {cls!r}. "
+ f"Use either `@register(some_class)` or plain `@register` "
+ f"on an annotated function."
+ )
+ func=cls
+
+ # only import typing if annotation parsing is necessary
+ fromtypingimportget_type_hints
+ argname,cls=next(iter(get_type_hints(func).items()))
+ ifnot_is_valid_dispatch_type(cls):
+ raiseTypeError(
+ f"Invalid annotation for {argname!r}. "
+ f"{cls!r} is not a class."
+ )
+
+ registry[cls]=func
+ ifcache_tokenisNoneandhasattr(cls,'__abstractmethods__'):
+ cache_token=get_cache_token()
+ dispatch_cache.clear()
+ returnfunc
+
+ defwrapper(*args,**kw):
+ ifnotargs:
+ raiseTypeError(f'{funcname} requires at least '
+ '1 positional argument')
+
+ returndispatch(args[0].__class__)(*args,**kw)
+
+ funcname=getattr(func,'__name__','singledispatch function')
+ registry[object]=func
+ wrapper.register=register
+ wrapper.dispatch=dispatch
+ wrapper.registry=types.MappingProxyType(registry)
+ wrapper._clear_cache=dispatch_cache.clear
+ update_wrapper(wrapper,func)
+ returnwrapper
+
+
+# Descriptor version
+classsingledispatchmethod:
+ """Single-dispatch generic method descriptor.
+
+ Supports wrapping existing descriptors and handles non-descriptor
+ callables as instance methods.
+ """
+
+ def__init__(self,func):
+ ifnotcallable(func)andnothasattr(func,"__get__"):
+ raiseTypeError(f"{func!r} is not callable or a descriptor")
+
+ self.dispatcher=singledispatch(func)
+ self.func=func
+
+ # bpo-45678: special-casing for classmethod/staticmethod in Python <=3.9,
+ # as functools.update_wrapper doesn't work properly in singledispatchmethod.__get__
+ # if it is applied to an unbound classmethod/staticmethod
+ ifisinstance(func,(staticmethod,classmethod)):
+ self._wrapped_func=func.__func__
+ else:
+ self._wrapped_func=func
+ defregister(self,cls,method=None):
+ """generic_method.register(cls, func) -> func
+
+ Registers a new implementation for the given *cls* on a *generic_method*.
+ """
+ # bpo-39679: in Python <= 3.9, classmethods and staticmethods don't
+ # inherit __annotations__ of the wrapped function (fixed in 3.10+ as
+ # a side-effect of bpo-43682) but we need that for annotation-derived
+ # singledispatches. So we add that just-in-time here.
+ ifisinstance(cls,(staticmethod,classmethod)):
+ cls.__annotations__=getattr(cls.__func__,'__annotations__',{})
+ returnself.dispatcher.register(cls,func=method)
+
+ def__get__(self,obj,cls=None):
+ def_method(*args,**kwargs):
+ method=self.dispatcher.dispatch(args[0].__class__)
+ returnmethod.__get__(obj,cls)(*args,**kwargs)
+
+ _method.__isabstractmethod__=self.__isabstractmethod__
+ _method.register=self.register
+ update_wrapper(_method,self._wrapped_func)
+ return_method
+
+ @property
+ def__isabstractmethod__(self):
+ returngetattr(self.func,'__isabstractmethod__',False)
+
+
+################################################################################
+### cached_property() - computed once per instance, cached as attribute
+################################################################################
+
+_NOT_FOUND=object()
+
+
+classcached_property:
+ def__init__(self,func):
+ self.func=func
+ self.attrname=None
+ self.__doc__=func.__doc__
+ self.lock=RLock()
+
+ def__set_name__(self,owner,name):
+ ifself.attrnameisNone:
+ self.attrname=name
+ elifname!=self.attrname:
+ raiseTypeError(
+ "Cannot assign the same cached_property to two different names "
+ f"({self.attrname!r} and {name!r})."
+ )
+
+ def__get__(self,instance,owner=None):
+ ifinstanceisNone:
+ returnself
+ ifself.attrnameisNone:
+ raiseTypeError(
+ "Cannot use cached_property instance without calling __set_name__ on it.")
+ try:
+ cache=instance.__dict__
+ exceptAttributeError:# not all objects have __dict__ (e.g. class defines slots)
+ msg=(
+ f"No '__dict__' attribute on {type(instance).__name__!r} "
+ f"instance to cache {self.attrname!r} property."
+ )
+ raiseTypeError(msg)fromNone
+ val=cache.get(self.attrname,_NOT_FOUND)
+ ifvalis_NOT_FOUND:
+ withself.lock:
+ # check if another thread filled cache while we awaited lock
+ val=cache.get(self.attrname,_NOT_FOUND)
+ ifvalis_NOT_FOUND:
+ val=self.func(instance)
+ try:
+ cache[self.attrname]=val
+ exceptTypeError:
+ msg=(
+ f"The '__dict__' attribute on {type(instance).__name__!r} instance "
+ f"does not support item assignment for caching {self.attrname!r} property."
+ )
+ raiseTypeError(msg)fromNone
+ returnval
+
+ __class_getitem__=classmethod(GenericAlias)
+
+
+
+
\ No newline at end of file
diff --git a/docs/0.9.5/_sources/A-voice-operated-elevator-using-events.md.txt b/docs/0.9.5/_sources/A-voice-operated-elevator-using-events.md.txt
new file mode 100644
index 0000000000..24f6bdef33
--- /dev/null
+++ b/docs/0.9.5/_sources/A-voice-operated-elevator-using-events.md.txt
@@ -0,0 +1,436 @@
+# A voice operated elevator using events
+
+
+- Previous tutorial: [Adding dialogues in events](./Dialogues-in-events.md)
+
+This tutorial will walk you through the steps to create a voice-operated elevator, using the [in-
+game Python
+system](https://github.com/evennia/evennia/blob/master/evennia/contrib/ingame_python/README.md).
+This tutorial assumes the in-game Python system is installed in your game. If it isn't, you can
+follow the installation steps given in [the documentation on in-game
+Python](https://github.com/evennia/evennia/blob/master/evennia/contrib/ingame_python/README.md), and
+come back on this tutorial once the system is installed. **You do not need to read** the entire
+documentation, it's a good reference, but not the easiest way to learn about it. Hence these
+tutorials.
+
+The in-game Python system allows to run code on individual objects in some situations. You don't
+have to modify the source code to add these features, past the installation. The entire system
+makes it easy to add specific features to some objects, but not all.
+
+> What will we try to do?
+
+In this tutorial, we are going to create a simple voice-operated elevator. In terms of features, we
+will:
+
+- Explore events with parameters.
+- Work on more interesting callbacks.
+- Learn about chained events.
+- Play with variable modification in callbacks.
+
+## Our study case
+
+Let's summarize what we want to achieve first. We would like to create a room that will represent
+the inside of our elevator. In this room, a character could just say "1", "2" or "3", and the
+elevator will start moving. The doors will close and open on the new floor (the exits leading in
+and out of the elevator will be modified).
+
+We will work on basic features first, and then will adjust some, showing you how easy and powerfully
+independent actions can be configured through the in-game Python system.
+
+## Creating the rooms and exits we need
+
+We'll create an elevator right in our room (generally called "Limbo", of ID 2). You could easily
+adapt the following instructions if you already have some rooms and exits, of course, just remember
+to check the IDs.
+
+> Note: the in-game Python system uses IDs for a lot of things. While it is not mandatory, it is
+good practice to know the IDs you have for your callbacks, because it will make manipulation much
+quicker. There are other ways to identify objects, but as they depend on many factors, IDs are
+usually the safest path in our callbacks.
+
+Let's go into limbo (`#2`) to add our elevator. We'll add it to the north. To create this room,
+in-game you could type:
+
+ tunnel n = Inside of an elevator
+
+The game should respond by telling you:
+
+ Created room Inside of an elevator(#3) of type typeclasses.rooms.Room.
+ Created Exit from Limbo to Inside of an elevator: north(#4) (n).
+ Created Exit back from Inside of an elevator to Limbo: south(#5) (s).
+
+Note the given IDs:
+
+- `#2` is limbo, the first room the system created.
+- `#3` is our room inside of an elevator.
+- `#4` is the north exit from Limbo to our elevator.
+- `#5` is the south exit from an elevator to Limbo.
+
+Keep these IDs somewhere for the demonstration. You will shortly see why they are important.
+
+> Why have we created exits to our elevator and back to Limbo? Isn't the elevator supposed to move?
+
+It is. But we need to have exits that will represent the way inside the elevator and out. What we
+will do, at every floor, will be to change these exits so they become connected to the right room.
+You'll see this process a bit later.
+
+We have two more rooms to create: our floor 2 and 3. This time, we'll use `dig`, because we don't
+need exits leading there, not yet anyway.
+
+ dig The second floor
+ dig The third floor
+
+Evennia should answer with:
+
+ Created room The second floor(#6) of type typeclasses.rooms.Room.
+ Created room The third floor(#7) of type typeclasses.rooms.Room.
+
+Add these IDs to your list, we will use them too.
+
+## Our first callback in the elevator
+
+Let's go to the elevator (you could use `tel #3` if you have the same IDs I have).
+
+This is our elevator room. It looks a bit empty, feel free to add a prettier description or other
+things to decorate it a bit.
+
+But what we want now is to be able to say "1", "2" or "3" and have the elevator move in that
+direction.
+
+If you have read [the previous tutorial about adding dialogues in events](./Dialogues-in-events.md), you
+may remember what we need to do. If not, here's a summary: we need to run some code when somebody
+speaks in the room. So we need to create a callback (the callback will contain our lines of code).
+We just need to know on which event this should be set. You can enter `call here` to see the
+possible events in this room.
+
+In the table, you should see the "say" event, which is called when somebody says something in the
+room. So we'll need to add a callback to this event. Don't worry if you're a bit lost, just follow
+the following steps, the way they connect together will become more obvious.
+
+ call/add here = say 1, 2, 3
+
+1. We need to add a callback. A callback contains the code that will be executed at a given time.
+So we use the `call/add` command and switch.
+2. `here` is our object, the room in which we are.
+3. An equal sign.
+4. The name of the event to which the callback should be connected. Here, the event is "say".
+Meaning this callback will be executed every time somebody says something in the room.
+5. But we add an event parameter to indicate the keywords said in the room that should execute our
+callback. Otherwise, our callback would be called every time somebody speaks, no matter what. Here
+we limit, indicating our callback should be executed only if the spoken message contains "1", "2" or
+"3".
+
+An editor should open, inviting you to enter the Python code that should be executed. The first
+thing to remember is to read the text provided (it can contain important information) and, most of
+all, the list of variables that are available in this callback:
+
+```
+Variables you can use in this event:
+
+ character: the character having spoken in this room.
+ room: the room connected to this event.
+ message: the text having been spoken by the character.
+
+----------Line Editor [Callback say of Inside of an elevator]---------------------
+01|
+----------[l:01 w:000 c:0000]------------(:h for help)----------------------------
+```
+
+This is important, in order to know what variables we can use in our callback out-of-the-box. Let's
+write a single line to be sure our callback is called when we expect it to:
+
+```python
+character.msg("You just said {}.".format(message))
+```
+
+You can paste this line in-game, then type the `:wq` command to exit the editor and save your
+modifications.
+
+Let's check. Try to say "hello" in the room. You should see the standard message, but nothing
+more. Now try to say "1". Below the standard message, you should see:
+
+ You just said 1.
+
+You can try it. Our callback is only called when we say "1", "2" or "3". Which is just what we
+want.
+
+Let's go back in our code editor and add something more useful.
+
+ call/edit here = say
+
+> Notice that we used the "edit" switch this time, since the callback exists, we just want to edit
+it.
+
+The editor opens again. Let's empty it first:
+
+ :DD
+
+And turn off automatic indentation, which will help us:
+
+ :=
+
+> Auto-indentation is an interesting feature of the code editor, but we'd better not use it at this
+point, it will make copy/pasting more complicated.
+
+## Our entire callback in the elevator
+
+So here's the time to truly code our callback in-game. Here's a little reminder:
+
+1. We have all the IDs of our three rooms and two exits.
+2. When we say "1", "2" or "3", the elevator should move to the right room, that is change the
+exits. Remember, we already have the exits, we just need to change their location and destination.
+
+It's a good idea to try to write this callback yourself, but don't feel bad about checking the
+solution right now. Here's a possible code that you could paste in the code editor:
+
+```python
+# First let's have some constants
+ELEVATOR = get(id=3)
+FLOORS = {
+ "1": get(id=2),
+ "2": get(id=6),
+ "3": get(id=7),
+}
+TO_EXIT = get(id=4)
+BACK_EXIT = get(id=5)
+
+# Now we check that the elevator isn't already at this floor
+floor = FLOORS.get(message)
+if floor is None:
+ character.msg("Which floor do you want?")
+elif TO_EXIT.location is floor:
+ character.msg("The elevator already is at this floor.")
+else:
+ # 'floor' contains the new room where the elevator should be
+ room.msg_contents("The doors of the elevator close with a clank.")
+ TO_EXIT.location = floor
+ BACK_EXIT.destination = floor
+ room.msg_contents("The doors of the elevator open to {floor}.",
+ mapping=dict(floor=floor))
+```
+
+Let's review this longer callback:
+
+1. We first obtain the objects of both exits and our three floors. We use the `get()` eventfunc,
+which is a shortcut to obtaining objects. We usually use it to retrieve specific objects with an
+ID. We put the floors in a dictionary. The keys of the dictionary are the floor number (as str),
+the values are room objects.
+2. Remember, the `message` variable contains the message spoken in the room. So either "1", "2", or
+"3". We still need to check it, however, because if the character says something like "1 2" in the
+room, our callback will be executed. Let's be sure what she says is a floor number.
+3. We then check if the elevator is already at this floor. Notice that we use `TO_EXIT.location`.
+`TO_EXIT` contains our "north" exit, leading inside of our elevator. Therefore, its `location` will
+be the room where the elevator currently is.
+4. If the floor is a different one, have the elevator "move", changing just the location and
+destination of both exits.
+ - The `BACK_EXIT` (that is "north") should change its location. The elevator shouldn't be
+accessible through our old floor.
+ - The `TO_EXIT` (that is "south", the exit leading out of the elevator) should have a different
+destination. When we go out of the elevator, we should find ourselves in the new floor, not the old
+one.
+
+Feel free to expand on this example, changing messages, making further checks. Usage and practice
+are keys.
+
+You can quit the editor as usual with `:wq` and test it out.
+
+## Adding a pause in our callback
+
+Let's improve our callback. One thing that's worth adding would be a pause: for the time being,
+when we say the floor number in the elevator, the doors close and open right away. It would be
+better to have a pause of several seconds. More logical.
+
+This is a great opportunity to learn about chained events. Chained events are very useful to create
+pauses. Contrary to the events we have seen so far, chained events aren't called automatically.
+They must be called by you, and can be called after some time.
+
+- Chained events always have the name "chain_X". Usually, X is a number, but you can give the
+chained event a more explicit name.
+- In our original callback, we will call our chained events in, say, 15 seconds.
+- We'll also have to make sure the elevator isn't already moving.
+
+Other than that, a chained event can be connected to a callback as usual. We'll create a chained
+event in our elevator, that will only contain the code necessary to open the doors to the new floor.
+
+ call/add here = chain_1
+
+The callback is added to the "chain_1" event, an event that will not be automatically called by the
+system when something happens. Inside this event, you can paste the code to open the doors at the
+new floor. You can notice a few differences:
+
+```python
+TO_EXIT.location = floor
+TO_EXIT.destination = ELEVATOR
+BACK_EXIT.location = ELEVATOR
+BACK_EXIT.destination = floor
+room.msg_contents("The doors of the elevator open to {floor}.",
+ mapping=dict(floor=floor))
+```
+
+Paste this code into the editor, then use `:wq` to save and quit the editor.
+
+Now let's edit our callback in the "say" event. We'll have to change it a bit:
+
+- The callback will have to check the elevator isn't already moving.
+- It must change the exits when the elevator move.
+- It has to call the "chain_1" event we have defined. It should call it 15 seconds later.
+
+Let's see the code in our callback.
+
+ call/edit here = say
+
+Remove the current code and disable auto-indentation again:
+
+ :DD
+ :=
+
+And you can paste instead the following code. Notice the differences with our first attempt:
+
+```python
+# First let's have some constants
+ELEVATOR = get(id=3)
+FLOORS = {
+ "1": get(id=2),
+ "2": get(id=6),
+ "3": get(id=7),
+}
+TO_EXIT = get(id=4)
+BACK_EXIT = get(id=5)
+
+# Now we check that the elevator isn't already at this floor
+floor = FLOORS.get(message)
+if floor is None:
+ character.msg("Which floor do you want?")
+elif BACK_EXIT.location is None:
+ character.msg("The elevator is between floors.")
+elif TO_EXIT.location is floor:
+ character.msg("The elevator already is at this floor.")
+else:
+ # 'floor' contains the new room where the elevator should be
+ room.msg_contents("The doors of the elevator close with a clank.")
+ TO_EXIT.location = None
+ BACK_EXIT.location = None
+ call_event(room, "chain_1", 15)
+```
+
+What changed?
+
+1. We added a little test to make sure the elevator wasn't already moving. If it is, the
+`BACK_EXIT.location` (the "south" exit leading out of the elevator) should be `None`. We'll remove
+the exit while the elevator is moving.
+2. When the doors close, we set both exits' `location` to `None`. Which "removes" them from their
+room but doesn't destroy them. The exits still exist but they don't connect anything. If you say
+"2" in the elevator and look around while the elevator is moving, you won't see any exits.
+3. Instead of opening the doors immediately, we call `call_event`. We give it the object containing
+the event to be called (here, our elevator), the name of the event to be called (here, "chain_1")
+and the number of seconds from now when the event should be called (here, `15`).
+4. The `chain_1` callback we have created contains the code to "re-open" the elevator doors. That
+is, besides displaying a message, it reset the exits' `location` and `destination`.
+
+If you try to say "3" in the elevator, you should see the doors closing. Look around you and you
+won't see any exit. Then, 15 seconds later, the doors should open, and you can leave the elevator
+to go to the third floor. While the elevator is moving, the exit leading to it will be
+inaccessible.
+
+> Note: we don't define the variables again in our chained event, we just call them. When we
+execute `call_event`, a copy of our current variables is placed in the database. These variables
+will be restored and accessible again when the chained event is called.
+
+You can use the `call/tasks` command to see the tasks waiting to be executed. For instance, say "2"
+in the room, notice the doors closing, and then type the `call/tasks` command. You will see a task
+in the elevator, waiting to call the `chain_1` event.
+
+## Changing exit messages
+
+Here's another nice little feature of events: you can modify the message of a single exit without
+altering the others. In this case, when someone goes north into our elevator, we'd like to see
+something like: "someone walks into the elevator." Something similar for the back exit would be
+great too.
+
+Inside of the elevator, you can look at the available events on the exit leading outside (south).
+
+ call south
+
+You should see two interesting rows in this table:
+
+```
+| msg_arrive | 0 (0) | Customize the message when a character |
+| | | arrives through this exit. |
+| msg_leave | 0 (0) | Customize the message when a character leaves |
+| | | through this exit. |
+```
+
+So we can change the message others see when a character leaves, by editing the "msg_leave" event.
+Let's do that:
+
+ call/add south = msg_leave
+
+Take the time to read the help. It gives you all the information you should need. We'll need to
+change the "message" variable, and use custom mapping (between braces) to alter the message. We're
+given an example, let's use it. In the code editor, you can paste the following line:
+
+```python
+message = "{character} walks out of the elevator."
+```
+
+Again, save and quit the editor by entering `:wq`. You can create a new character to see it leave.
+
+ charcreate A beggar
+ tel #8 = here
+
+(Obviously, adapt the ID if necessary.)
+
+ py self.search("beggar").move_to(self.search("south"))
+
+This is a crude way to force our beggar out of the elevator, but it allows us to test. You should
+see:
+
+ A beggar(#8) walks out of the elevator.
+
+Great! Let's do the same thing for the exit leading inside of the elevator. Follow the beggar,
+then edit "msg_leave" of "north":
+
+ call/add north = msg_leave
+
+```python
+message = "{character} walks into the elevator."
+```
+
+Again, you can force our beggar to move and see the message we have just set. This modification
+applies to these two exits, obviously: the custom message won't be used for other exits. Since we
+use the same exits for every floor, this will be available no matter at what floor the elevator is,
+which is pretty neat!
+
+## Tutorial F.A.Q.
+
+- **Q:** what happens if the game reloads or shuts down while a task is waiting to happen?
+- **A:** if your game reloads while a task is in pause (like our elevator between floors), when the
+game is accessible again, the task will be called (if necessary, with a new time difference to take
+into account the reload). If the server shuts down, obviously, the task will not be called, but
+will be stored and executed when the server is up again.
+- **Q:** can I use all kinds of variables in my callback? Whether chained or not?
+- **A:** you can use every variable type you like in your original callback. However, if you
+execute `call_event`, since your variables are stored in the database, they will need to respect the
+constraints on persistent attributes. A callback will not be stored in this way, for instance.
+This variable will not be available in your chained event.
+- **Q:** when you say I can call my chained events something else than "chain_1", "chain_2" and
+such, what is the naming convention?
+- **A:** chained events have names beginning by "chain_". This is useful for you and for the
+system. But after the underscore, you can give a more useful name, like "chain_open_doors" in our
+case.
+- **Q:** do I have to pause several seconds to call a chained event?
+- **A:** no, you can call it right away. Just leave the third parameter of `call_event` out (it
+will default to 0, meaning the chained event will be called right away). This will not create a
+task.
+- **Q:** can I have chained events calling themselves?
+- **A:** you can. There's no limitation. Just be careful, a callback that calls itself,
+particularly without delay, might be a good recipe for an infinite loop. However, in some cases, it
+is useful to have chained events calling themselves, to do the same repeated action every X seconds
+for instance.
+- **Q:** what if I need several elevators, do I need to copy/paste these callbacks each time?
+- **A:** not advisable. There are definitely better ways to handle this situation. One of them is
+to consider adding the code in the source itself. Another possibility is to call chained events
+with the expected behavior, which makes porting code very easy. This side of chained events will be
+shown in the next tutorial.
+
+- Previous tutorial: [Adding dialogues in events](./Dialogues-in-events.md)
diff --git a/docs/0.9.5/_sources/API-refactoring.md.txt b/docs/0.9.5/_sources/API-refactoring.md.txt
new file mode 100644
index 0000000000..689b5893f5
--- /dev/null
+++ b/docs/0.9.5/_sources/API-refactoring.md.txt
@@ -0,0 +1,46 @@
+# API refactoring
+
+Building up to Evennia 1.0 and beyond, it's time to comb through the Evennia API for old cruft. This
+whitepage is for anyone interested to contribute with their views on what part of the API needs
+refactoring, cleanup or clarification (or extension!)
+
+Note that this is not a forum. To keep things clean, each opinion text should ideally present a
+clear argument or lay out a suggestion. Asking for clarification and any side-discussions should be
+held in chat or forum.
+
+---
+
+### Griatch (Aug 13, 2019)
+
+This is how to enter an opinion. Use any markdown needed but stay within your section. Also remember
+to copy your text to the clipboard before saving since if someone else edited the wiki in the
+meantime you'll have to start over.
+
+### Griatch (Sept 2, 2019)
+
+I don't agree with removing explicit keywords as suggested by [Johnny on Aug 29 below](API-
+refactoring#reduce-usage-of-optionalpositional-arguments-aug-29-2019). Overriding such a method can
+still be done by `get(self, **kwargs)` if so desired, making the kwargs explicit helps IMO
+readability of the API. If just giving a generic `**kwargs`, one must read the docstring or even the
+code to see which keywords are valid.
+
+On the other hand, I think it makes sense to as a standard offer an extra `**kwargs` at the end of
+arg-lists for common methods that are expected to be over-ridden. This make the API more flexible by
+hinting to the dev that they could expand their own over-ridden implementation with their own
+keyword arguments if so desired.
+
+---
+
+### Johnny
+
+#### Reduce usage of optional/positional arguments (Aug 29, 2019)
+```
+# AttributeHandler
+def get(self, key=None, default=None, category=None, return_obj=False,
+ strattr=False, raise_exception=False, accessing_obj=None,
+ default_access=True, return_list=False):
+```
+Many classes have methods requiring lengthy positional argument lists, which are tedious and error-
+prone to extend and override especially in cases where not all arguments are even required. It would
+be useful if arguments were reserved for required inputs and anything else relegated to kwargs for
+easier passthrough on extension.
diff --git a/docs/0.9.5/_sources/Accounts.md.txt b/docs/0.9.5/_sources/Accounts.md.txt
new file mode 100644
index 0000000000..6d61136583
--- /dev/null
+++ b/docs/0.9.5/_sources/Accounts.md.txt
@@ -0,0 +1,108 @@
+# Accounts
+
+
+All *users* (real people) that starts a game [Session](./Sessions.md) on Evennia are doing so through an
+object called *Account*. The Account object has no in-game representation, it represents a unique
+game account. In order to actually get on the game the Account must *puppet* an [Object](./Objects.md)
+(normally a [Character](./Objects.md#characters)).
+
+Exactly how many Sessions can interact with an Account and its Puppets at once is determined by
+Evennia's [MULTISESSION_MODE](./Sessions.md#multisession-mode) setting.
+
+Apart from storing login information and other account-specific data, the Account object is what is
+chatting on [Channels](./Communications.md). It is also a good place to store [Permissions](./Locks.md) to be
+consistent between different in-game characters as well as configuration options. The Account
+object also has its own [CmdSet](./Command-Sets.md), the `AccountCmdSet`.
+
+Logged into default evennia, you can use the `ooc` command to leave your current
+[character](./Objects.md) and go into OOC mode. You are quite limited in this mode, basically it works
+like a simple chat program. It acts as a staging area for switching between Characters (if your
+game supports that) or as a safety mode if your Character gets deleted. Use `ic` to attempt to
+(re)puppet a Character.
+
+Note that the Account object can have, and often does have, a different set of
+[Permissions](./Locks.md#permissions) from the Character they control. Normally you should put your
+permissions on the Account level - this will overrule permissions set on the Character level. For
+the permissions of the Character to come into play the default `quell` command can be used. This
+allows for exploring the game using a different permission set (but you can't escalate your
+permissions this way - for hierarchical permissions like `Builder`, `Admin` etc, the *lower* of the
+permissions on the Character/Account will always be used).
+
+## How to create your own Account types
+
+You will usually not want more than one Account typeclass for all new accounts (but you could in
+principle create a system that changes an account's typeclass dynamically).
+
+An Evennia Account is, per definition, a Python class that includes `evennia.DefaultAccount` among
+its parents. In `mygame/typeclasses/accounts.py` there is an empty class ready for you to modify.
+Evennia defaults to using this (it inherits directly from `DefaultAccount`).
+
+Here's an example of modifying the default Account class in code:
+
+```python
+ # in mygame/typeclasses/accounts.py
+
+ from evennia import DefaultAccount
+
+ class Account(DefaultAccount): # [...]
+
+ at_account_creation(self): "this is called only once, when account is first created"
+ self.db.real_name = None # this is set later self.db.real_address = None #
+"
+ self.db.config_1 = True # default config self.db.config_2 = False # "
+ self.db.config_3 = 1 # "
+
+ # ... whatever else our game needs to know ``` Reload the server with `reload`.
+
+```
+
+... However, if you use `examine *self` (the asterisk makes you examine your Account object rather
+than your Character), you won't see your new Attributes yet. This is because `at_account_creation`
+is only called the very *first* time the Account is called and your Account object already exists
+(any new Accounts that connect will see them though). To update yourself you need to make sure to
+re-fire the hook on all the Accounts you have already created. Here is an example of how to do this
+using `py`:
+
+
+``` py [account.at_account_creation() for account in evennia.managers.accounts.all()] ```
+
+You should now see the Attributes on yourself.
+
+
+> If you wanted Evennia to default to a completely *different* Account class located elsewhere, you
+> must point Evennia to it. Add `BASE_ACCOUNT_TYPECLASS` to your settings file, and give the python
+> path to your custom class as its value. By default this points to `typeclasses.accounts.Account`,
+> the empty template we used above.
+
+
+## Properties on Accounts
+
+Beyond those properties assigned to all typeclassed objects (see [Typeclasses](./Typeclasses.md)), the
+Account also has the following custom properties:
+
+- `user` - a unique link to a `User` Django object, representing the logged-in user.
+- `obj` - an alias for `character`.
+- `name` - an alias for `user.username`
+- `sessions` - an instance of
+ [ObjectSessionHandler](github:evennia.objects.objects#objectsessionhandler)
+ managing all connected Sessions (physical connections) this object listens to (Note: In older
+ versions of Evennia, this was a list). The so-called `session-id` (used in many places) is found
+as
+ a property `sessid` on each Session instance.
+- `is_superuser` (bool: True/False) - if this account is a superuser.
+
+Special handlers:
+- `cmdset` - This holds all the current [Commands](./Commands.md) of this Account. By default these are
+ the commands found in the cmdset defined by `settings.CMDSET_ACCOUNT`.
+- `nicks` - This stores and handles [Nicks](./Nicks.md), in the same way as nicks it works on Objects.
+ For Accounts, nicks are primarily used to store custom aliases for
+[Channels](./Communications.md#channels).
+
+Selection of special methods (see `evennia.DefaultAccount` for details):
+- `get_puppet` - get a currently puppeted object connected to the Account and a given session id, if
+ any.
+- `puppet_object` - connect a session to a puppetable Object.
+- `unpuppet_object` - disconnect a session from a puppetable Object.
+- `msg` - send text to the Account
+- `execute_cmd` - runs a command as if this Account did it.
+- `search` - search for Accounts.
diff --git a/docs/0.9.5/_sources/Add-a-simple-new-web-page.md.txt b/docs/0.9.5/_sources/Add-a-simple-new-web-page.md.txt
new file mode 100644
index 0000000000..c146a54f10
--- /dev/null
+++ b/docs/0.9.5/_sources/Add-a-simple-new-web-page.md.txt
@@ -0,0 +1,100 @@
+# Add a simple new web page
+
+
+Evennia leverages [Django](https://docs.djangoproject.com) which is a web development framework.
+Huge professional websites are made in Django and there is extensive documentation (and books) on it
+. You are encouraged to at least look at the Django basic tutorials. Here we will just give a brief
+introduction for how things hang together, to get you started.
+
+We assume you have installed and set up Evennia to run. A webserver and website comes out of the
+box. You can get to that by entering `http://localhost:4001` in your web browser - you should see a
+welcome page with some game statistics and a link to the web client. Let us add a new page that you
+can get to by going to `http://localhost:4001/story`.
+
+### Create the view
+
+A django "view" is a normal Python function that django calls to render the HTML page you will see
+in the web browser. Here we will just have it spit back the raw html, but Django can do all sorts of
+cool stuff with the page in the view, like adding dynamic content or change it on the fly. Open
+`mygame/web` folder and add a new module there named `story.py` (you could also put it in its own
+folder if you wanted to be neat. Don't forget to add an empty `__init__.py` file if you do, to tell
+Python you can import from the new folder). Here's how it looks:
+
+```python
+# in mygame/web/story.py
+
+from django.shortcuts import render
+
+def storypage(request):
+ return render(request, "story.html")
+```
+
+This view takes advantage of a shortcut provided to use by Django, _render_. This shortcut gives the
+template some information from the request, for instance, the game name, and then renders it.
+
+### The HTML page
+
+We need to find a place where Evennia (and Django) looks for html files (called *templates* in
+Django parlance). You can specify such places in your settings (see the `TEMPLATES` variable in
+`default_settings.py` for more info), but here we'll use an existing one. Go to
+`mygame/template/overrides/website/` and create a page `story.html` there.
+
+This is not a HTML tutorial, so we'll go simple:
+
+```html
+{% extends "base.html" %}
+{% block content %}
+
+
+
A story about a tree
+
+ This is a story about a tree, a classic tale ...
+
+
+
+{% endblock %}
+```
+
+Since we've used the _render_ shortcut, Django will allow us to extend our base styles easily.
+
+If you'd rather not take advantage of Evennia's base styles, you can do something like this instead:
+
+```html
+
+
+
A story about a tree
+
+ This is a story about a tree, a classic tale ...
+
+
+```
+
+
+### The URL
+
+When you enter the address `http://localhost:4001/story` in your web browser, Django will parse that
+field to figure out which page you want to go to. You tell it which patterns are relevant in the
+file
+[mygame/web/urls.py](https://github.com/evennia/evennia/blob/master/evennia/game_template/web/urls.py).
+Open it now.
+
+Django looks for the variable `urlpatterns` in this file. You want to add your new pattern to the
+`custom_patterns` list we have prepared - that is then merged with the default `urlpatterns`. Here's
+how it could look:
+
+```python
+from web import story
+
+# ...
+
+custom_patterns = [
+ url(r'story', story.storypage, name='Story'),
+]
+```
+
+That is, we import our story view module from where we created it earlier and then create an `url`
+instance. The first argument to `url` is the pattern of the url we want to find (`"story"`) (this is
+a regular expression if you are familiar with those) and then our view function we want to direct
+to.
+
+That should be it. Reload Evennia and you should be able to browse to your new story page!
diff --git a/docs/0.9.5/_sources/Add-a-wiki-on-your-website.md.txt b/docs/0.9.5/_sources/Add-a-wiki-on-your-website.md.txt
new file mode 100644
index 0000000000..a8f476d28e
--- /dev/null
+++ b/docs/0.9.5/_sources/Add-a-wiki-on-your-website.md.txt
@@ -0,0 +1,232 @@
+# Add a wiki on your website
+
+
+**Before doing this tutorial you will probably want to read the intro in
+[Basic Web tutorial](./Web-Tutorial.md).** Reading the three first parts of the
+[Django tutorial](https://docs.djangoproject.com/en/1.9/intro/tutorial01/) might help as well.
+
+This tutorial will provide a step-by-step process to installing a wiki on your website.
+Fortunately, you don't have to create the features manually, since it has been done by others, and
+we can integrate their work quite easily with Django. I have decided to focus on
+the [Django-wiki](http://django-wiki.readthedocs.io/).
+
+> Note: this article has been updated for Evennia 0.9. If you're not yet using this version, be
+careful, as the django wiki doesn't support Python 2 anymore. (Remove this note when enough time
+has passed.)
+
+The [Django-wiki](http://django-wiki.readthedocs.io/) offers a lot of features associated with
+wikis, is
+actively maintained (at this time, anyway), and isn't too difficult to install in Evennia. You can
+see a [demonstration of Django-wiki here](https://demo.django.wiki).
+
+## Basic installation
+
+You should begin by shutting down the Evennia server if it is running. We will run migrations and
+alter the virtual environment just a bit. Open a terminal and activate your Python environment, the
+one you use to run the `evennia` command.
+
+* On Linux:
+ ```
+ source evenv/bin/activate
+ ```
+* Or Windows:
+ ```
+ evenv\bin\activate
+ ```
+
+### Installing with pip
+
+Install the wiki using pip:
+
+ pip install wiki
+
+> Note: this will install the last version of Django wiki. Version >0.4 doesn't support Python 2, so
+install wiki 0.3 if you haven't updated to Python 3 yet.
+
+It might take some time, the Django-wiki having some dependencies.
+
+### Adding the wiki in the settings
+
+You will need to add a few settings to have the wiki app on your website. Open your
+`server/conf/settings.py` file and add the following at the bottom (but before importing
+`secret_settings`). Here's what you'll find in my own setting file (add the whole Django-wiki
+section):
+
+```python
+r"""
+Evennia settings file.
+
+...
+
+"""
+
+# Use the defaults from Evennia unless explicitly overridden
+from evennia.settings_default import *
+
+######################################################################
+# Evennia base server config
+######################################################################
+
+# This is the name of your game. Make it catchy!
+SERVERNAME = "demowiki"
+
+######################################################################
+# Django-wiki settings
+######################################################################
+INSTALLED_APPS += (
+ 'django.contrib.humanize.apps.HumanizeConfig',
+ 'django_nyt.apps.DjangoNytConfig',
+ 'mptt',
+ 'sorl.thumbnail',
+ 'wiki.apps.WikiConfig',
+ 'wiki.plugins.attachments.apps.AttachmentsConfig',
+ 'wiki.plugins.notifications.apps.NotificationsConfig',
+ 'wiki.plugins.images.apps.ImagesConfig',
+ 'wiki.plugins.macros.apps.MacrosConfig',
+)
+
+# Disable wiki handling of login/signup
+WIKI_ACCOUNT_HANDLING = False
+WIKI_ACCOUNT_SIGNUP_ALLOWED = False
+
+######################################################################
+# Settings given in secret_settings.py override those in this file.
+######################################################################
+try:
+ from server.conf.secret_settings import *
+except ImportError:
+ print("secret_settings.py file not found or failed to import.")
+```
+
+### Adding the new URLs
+
+Next we need to add two URLs in our `web/urls.py` file. Open it and compare the following output:
+you will need to add two URLs in `custom_patterns` and add one import line:
+
+```python
+from django.conf.urls import url, include
+from django.urls import path # NEW!
+
+# default evenni a patterns
+from evennia.web.urls import urlpatterns
+
+# eventual custom patterns
+custom_patterns = [
+ # url(r'/desired/url/', view, name='example'),
+ url('notifications/', include('django_nyt.urls')), # NEW!
+ url('wiki/', include('wiki.urls')), # NEW!
+]
+
+# this is required by Django.
+urlpatterns = custom_patterns + urlpatterns
+```
+
+You will probably need to copy line 2, 10, and 11. Be sure to place them correctly, as shown in
+the example above.
+
+### Running migrations
+
+It's time to run the new migrations. The wiki app adds a few tables in our database. We'll need to
+run:
+
+ evennia migrate
+
+And that's it, you can start the server. If you go to http://localhost:4001/wiki , you should see
+the wiki. Use your account's username and password to connect to it. That's how simple it is.
+
+## Customizing privileges
+
+A wiki can be a great collaborative tool, but who can see it? Who can modify it? Django-wiki comes
+with a privilege system centered around four values per wiki page. The owner of an article can
+always read and write in it (which is somewhat logical). The group of the article defines who can
+read and who can write, if the user seeing the page belongs to this group. The topic of groups in
+wiki pages will not be discussed here. A last setting determines which other user (that is, these
+who aren't in the groups, and aren't the article's owner) can read and write. Each article has
+these four settings (group read, group write, other read, other write). Depending on your purpose,
+it might not be a good default choice, particularly if you have to remind every builder to keep the
+pages private. Fortunately, Django-wiki gives us additional settings to customize who can read, and
+who can write, a specific article.
+
+These settings must be placed, as usual, in your `server/conf/settings.py` file. They take a
+function as argument, said function (or callback) will be called with the article and the user.
+Remember, a Django user, for us, is an account. So we could check lockstrings on them if needed.
+Here is a default setting to restrict the wiki: only builders can write in it, but anyone (including
+non-logged in users) can read it. The superuser has some additional privileges.
+
+```python
+# In server/conf/settings.py
+# ...
+
+def is_superuser(article, user):
+ """Return True if user is a superuser, False otherwise."""
+ return not user.is_anonymous() and user.is_superuser
+
+def is_builder(article, user):
+ """Return True if user is a builder, False otherwise."""
+ return not user.is_anonymous() and user.locks.check_lockstring(user, "perm(Builders)")
+
+def is_anyone(article, user):
+ """Return True even if the user is anonymous."""
+ return True
+
+# Who can create new groups and users from the wiki?
+WIKI_CAN_ADMIN = is_superuser
+# Who can change owner and group membership?
+WIKI_CAN_ASSIGN = is_superuser
+# Who can change group membership?
+WIKI_CAN_ASSIGN_OWNER = is_superuser
+# Who can change read/write access to groups or others?
+WIKI_CAN_CHANGE_PERMISSIONS = is_superuser
+# Who can soft-delete an article?
+WIKI_CAN_DELETE = is_builder
+# Who can lock an article and permanently delete it?
+WIKI_CAN_MODERATE = is_superuser
+# Who can edit articles?
+WIKI_CAN_WRITE = is_builder
+# Who can read articles?
+WIKI_CAN_READ = is_anyone
+```
+
+Here, we have created three functions: one to return `True` if the user is the superuser, one to
+return `True` if the user is a builder, one to return `True` no matter what (this includes if the
+user is anonymous, E.G. if it's not logged-in). We then change settings to allow either the
+superuser or
+each builder to moderate, read, write, delete, and more. You can, of course, add more functions,
+adapting them to your need. This is just a demonstration.
+
+Providing the `WIKI_CAN*...` settings will bypass the original permission system. The superuser
+could change permissions of an article, but still, only builders would be able to write it. If you
+need something more custom, you will have to expand on the functions you use.
+
+### Managing wiki pages from Evennia
+
+Unfortunately, Django wiki doesn't provide a clear and clean entry point to read and write articles
+from Evennia and it doesn't seem to be a very high priority. If you really need to keep Django wiki
+and to create and manage wiki pages from your code, you can do so, but this article won't elaborate,
+as this is somewhat more technical.
+
+However, it is a good opportunity to present a small project that has been created more recently:
+[evennia-wiki](https://github.com/vincent-lg/evennia-wiki) has been created to provide a simple
+wiki, more tailored to Evennia and easier to connect. It doesn't, as yet, provide as many options
+as does Django wiki, but it's perfectly usable:
+
+- Pages have an inherent and much-easier to understand hierarchy based on URLs.
+- Article permissions are connected to Evennia groups and are much easier to accommodate specific
+requirements.
+- Articles can easily be created, read or updated from the Evennia code itself.
+- Markdown is fully-supported with a default integration to Bootstrap to look good on an Evennia
+website. Tables and table of contents are supported as well as wiki links.
+- The process to override wiki templates makes full use of the `template_overrides` directory.
+
+However evennia-wiki doesn't yet support:
+
+- Images in markdown and the uploading schema. If images are important to you, please consider
+contributing to this new project.
+- Modifying permissions on a per page/setting basis.
+- Moving pages to new locations.
+- Viewing page history.
+
+Considering the list of features in Django wiki, obviously other things could be added to the list.
+However, these features may be the most important and useful. Additional ones might not be that
+necessary. If you're interested in supporting this little project, you are more than welcome to
+[contribute to it](https://github.com/vincent-lg/evennia-wiki). Thanks!
\ No newline at end of file
diff --git a/docs/0.9.5/_sources/Adding-Command-Tutorial.md.txt b/docs/0.9.5/_sources/Adding-Command-Tutorial.md.txt
new file mode 100644
index 0000000000..569c12252d
--- /dev/null
+++ b/docs/0.9.5/_sources/Adding-Command-Tutorial.md.txt
@@ -0,0 +1,171 @@
+# Adding Command Tutorial
+
+This is a quick first-time tutorial expanding on the [Commands](./Commands.md) documentation.
+
+Let's assume you have just downloaded Evennia, installed it and created your game folder (let's call
+it just `mygame` here). Now you want to try to add a new command. This is the fastest way to do it.
+
+## Step 1: Creating a custom command
+
+1. Open `mygame/commands/command.py` in a text editor. This is just one place commands could be
+placed but you get it setup from the onset as an easy place to start. It also already contains some
+example code.
+1. Create a new class in `command.py` inheriting from `default_cmds.MuxCommand`. Let's call it
+ `CmdEcho` in this example.
+1. Set the class variable `key` to a good command name, like `echo`.
+1. Give your class a useful _docstring_. A docstring is the string at the very top of a class or
+function/method. The docstring at the top of the command class is read by Evennia to become the help
+entry for the Command (see
+ [Command Auto-help](./Help-System.md#command-auto-help-system)).
+1. Define a class method `func(self)` that echoes your input back to you.
+
+Below is an example how this all could look for the echo command:
+
+```python
+ # file mygame/commands/command.py
+ #[...]
+ from evennia import default_cmds
+ class CmdEcho(default_cmds.MuxCommand):
+ """
+ Simple command example
+
+ Usage:
+ echo [text]
+
+ This command simply echoes text back to the caller.
+ """
+
+ key = "echo"
+
+ def func(self):
+ "This actually does things"
+ if not self.args:
+ self.caller.msg("You didn't enter anything!")
+ else:
+ self.caller.msg("You gave the string: '%s'" % self.args)
+```
+
+## Step 2: Adding the Command to a default Cmdset
+
+The command is not available to use until it is part of a [Command Set](./Command-Sets.md). In this
+example we will go the easiest route and add it to the default Character commandset that already
+exists.
+
+1. Edit `mygame/commands/default_cmdsets.py`
+1. Import your new command with `from commands.command import CmdEcho`.
+1. Add a line `self.add(CmdEcho())` to `CharacterCmdSet`, in the `at_cmdset_creation` method (the
+ template tells you where).
+
+This is approximately how it should look at this point:
+
+```python
+ # file mygame/commands/default_cmdsets.py
+ #[...]
+ from commands.command import CmdEcho
+ #[...]
+ class CharacterCmdSet(default_cmds.CharacterCmdSet):
+
+ key = "DefaultCharacter"
+
+ def at_cmdset_creation(self):
+
+ # this first adds all default commands
+ super().at_cmdset_creation()
+
+ # all commands added after this point will extend or
+ # overwrite the default commands.
+ self.add(CmdEcho())
+```
+
+Next, run the `@reload` command. You should now be able to use your new `echo` command from inside
+the game. Use `help echo` to see the documentation for the command.
+
+If you have trouble, make sure to check the log for error messages (probably due to syntax errors in
+your command definition).
+
+> Note: Typing `echotest` will also work. It will be handled as the command `echo` directly followed
+by
+its argument `test` (which will end up in `self.args). To change this behavior, you can add the
+`arg_regex` property alongside `key`, `help_category` etc. [See the arg_regex
+documentation](./Commands.md#on-arg_regex) for more info.
+
+If you want to overload existing default commands (such as `look` or `get`), just add your new
+command with the same key as the old one - it will then replace it. Just remember that you must use
+`@reload` to see any changes.
+
+See [Commands](./Commands.md) for many more details and possibilities when defining Commands and using
+Cmdsets in various ways.
+
+
+## Adding the command to specific object types
+
+Adding your Command to the `CharacterCmdSet` is just one easy exapmple. The cmdset system is very
+generic. You can create your own cmdsets (let's say in a module `mycmdsets.py`) and add them to
+objects as you please (how to control their merging is described in detail in the [Command Set
+documentation](./Command-Sets.md)).
+
+```python
+ # file mygame/commands/mycmdsets.py
+ #[...]
+ from commands.command import CmdEcho
+ from evennia import CmdSet
+ #[...]
+ class MyCmdSet(CmdSet):
+
+ key = "MyCmdSet"
+
+ def at_cmdset_creation(self):
+ self.add(CmdEcho())
+```
+Now you just need to add this to an object. To test things (as superuser) you can do
+
+ @py self.cmdset.add("mycmdsets.MyCmdSet")
+
+This will add this cmdset (along with its echo command) to yourself so you can test it. Note that
+you cannot add a single Command to an object on its own, it must be part of a CommandSet in order to
+do so.
+
+The Command you added is not there permanently at this point. If you do a `@reload` the merger will
+be gone. You *could* add the `permanent=True` keyword to the `cmdset.add` call. This will however
+only make the new merged cmdset permanent on that *single* object. Often you want *all* objects of
+this particular class to have this cmdset.
+
+To make sure all new created objects get your new merged set, put the `cmdset.add` call in your
+custom [Typeclasses](./Typeclasses.md)' `at_object_creation` method:
+
+```python
+ # e.g. in mygame/typeclasses/objects.py
+
+ from evennia import DefaultObject
+ class MyObject(DefaultObject):
+
+ def at_object_creation(self):
+ "called when the object is first created"
+ self.cmdset.add("mycmdset.MyCmdSet", permanent=True)
+```
+
+All new objects of this typeclass will now start with this cmdset and it will survive a `@reload`.
+
+*Note:* An important caveat with this is that `at_object_creation` is only called *once*, when the
+object is first created. This means that if you already have existing objects in your databases
+using that typeclass, they will not have been initiated the same way. There are many ways to update
+them; since it's a one-time update you can usually just simply loop through them. As superuser, try
+the following:
+
+ @py from typeclasses.objects import MyObject; [o.cmdset.add("mycmdset.MyCmdSet") for o in
+MyObject.objects.all()]
+
+This goes through all objects in your database having the right typeclass, adding the new cmdset to
+each. The good news is that you only have to do this if you want to post-add *cmdsets*. If you just
+want to add a new *command*, you can simply add that command to the cmdset's `at_cmdset_creation`
+and `@reload` to make the Command immediately available.
+
+## Change where Evennia looks for command sets
+
+Evennia uses settings variables to know where to look for its default command sets. These are
+normally not changed unless you want to re-organize your game folder in some way. For example, the
+default character cmdset defaults to being defined as
+
+ CMDSET_CHARACTER="commands.default_cmdset.CharacterCmdSet"
+
+See `evennia/settings_default.py` for the other settings.
diff --git a/docs/0.9.5/_sources/Adding-Object-Typeclass-Tutorial.md.txt b/docs/0.9.5/_sources/Adding-Object-Typeclass-Tutorial.md.txt
new file mode 100644
index 0000000000..4e4e0b66bb
--- /dev/null
+++ b/docs/0.9.5/_sources/Adding-Object-Typeclass-Tutorial.md.txt
@@ -0,0 +1,109 @@
+# Adding Object Typeclass Tutorial
+
+Evennia comes with a few very basic classes of in-game entities:
+
+ DefaultObject
+ |
+ DefaultCharacter
+ DefaultRoom
+ DefaultExit
+ DefaultChannel
+
+When you create a new Evennia game (with for example `evennia --init mygame`) Evennia will
+automatically create empty child classes `Object`, `Character`, `Room` and `Exit` respectively. They
+are found `mygame/typeclasses/objects.py`, `mygame/typeclasses/rooms.py` etc.
+
+> Technically these are all [Typeclassed](./Typeclasses.md), which can be ignored for now. In
+> `mygame/typeclasses` are also base typeclasses for out-of-character things, notably
+> [Channels](./Communications.md), [Accounts](./Accounts.md) and [Scripts](./Scripts.md). We don't cover those in
+> this tutorial.
+
+For your own game you will most likely want to expand on these very simple beginnings. It's normal
+to want your Characters to have various attributes, for example. Maybe Rooms should hold extra
+information or even *all* Objects in your game should have properties not included in basic Evennia.
+
+## Change Default Rooms, Exits, Character Typeclass
+
+This is the simplest case.
+
+The default build commands of a new Evennia game is set up to use the `Room`, `Exit` and `Character`
+classes found in the same-named modules under `mygame/typeclasses/`. By default these are empty and
+just implements the default parents from the Evennia library (`DefaultRoom`etc). Just add the
+changes you want to these classes and run `@reload` to add your new functionality.
+
+## Create a new type of object
+
+Say you want to create a new "Heavy" object-type that characters should not have the ability to pick
+up.
+
+1. Edit `mygame/typeclasses/objects.py` (you could also create a new module there, named something
+ like `heavy.py`, that's up to how you want to organize things).
+1. Create a new class inheriting at any distance from `DefaultObject`. It could look something like
+ this:
+```python
+ # end of file mygame/typeclasses/objects.py
+ from evennia import DefaultObject
+
+ class Heavy(DefaultObject):
+ "Heavy object"
+ def at_object_creation(self):
+ "Called whenever a new object is created"
+ # lock the object down by default
+ self.locks.add("get:false()")
+ # the default "get" command looks for this Attribute in order
+ # to return a customized error message (we just happen to know
+ # this, you'd have to look at the code of the 'get' command to
+ # find out).
+ self.db.get_err_msg = "This is too heavy to pick up."
+```
+1. Once you are done, log into the game with a build-capable account and do `@create/drop
+ rock:objects.Heavy` to drop a new heavy "rock" object in your location. Next try to pick it up
+(`@quell` yourself first if you are a superuser). If you get errors, look at your log files where
+you will find the traceback. The most common error is that you have some sort of syntax error in
+your class.
+
+Note that the [Locks](./Locks.md) and [Attribute](./Attributes.md) which are set in the typeclass could just
+as well have been set using commands in-game, so this is a *very* simple example.
+
+## Storing data on initialization
+
+The `at_object_creation` is only called once, when the object is first created. This makes it ideal
+for database-bound things like [Attributes](./Attributes.md). But sometimes you want to create temporary
+properties (things that are not to be stored in the database but still always exist every time the
+object is created). Such properties can be initialized in the `at_init` method on the object.
+`at_init` is called every time the object is loaded into memory.
+
+> Note: It's usually pointless and wasteful to assign database data in `at_init`, since this will
+> hit the database with the same value over and over. Put those in `at_object_creation` instead.
+
+You are wise to use `ndb` (non-database Attributes) to store these non-persistent properties, since
+ndb-properties are protected against being cached out in various ways and also allows you to list
+them using various in-game tools:
+
+```python
+def at_init(self):
+ self.ndb.counter = 0
+ self.ndb.mylist = []
+```
+
+> Note: As mentioned in the [Typeclasses](./Typeclasses.md) documentation, `at_init` replaces the use of
+> the standard `__init__` method of typeclasses due to how the latter may be called in situations
+> other than you'd expect. So use `at_init` where you would normally use `__init__`.
+
+
+## Updating existing objects
+
+If you already have some `Heavy` objects created and you add a new `Attribute` in
+`at_object_creation`, you will find that those existing objects will not have this Attribute. This
+is not so strange, since `at_object_creation` is only called once, it will not be called again just
+because you update it. You need to update existing objects manually.
+
+If the number of objects is limited, you can use `@typeclass/force/reload objectname` to force a
+re-load of the `at_object_creation` method (only) on the object. This case is common enough that
+there is an alias `@update objectname` you can use to get the same effect. If there are multiple
+objects you can use `@py` to loop over the objects you need:
+
+```
+@py from typeclasses.objects import Heavy; [obj.at_object_creation() for obj in Heavy.objects.all()]
+
+```
diff --git a/docs/0.9.5/_sources/Administrative-Docs.md.txt b/docs/0.9.5/_sources/Administrative-Docs.md.txt
new file mode 100644
index 0000000000..154fbd83ec
--- /dev/null
+++ b/docs/0.9.5/_sources/Administrative-Docs.md.txt
@@ -0,0 +1,76 @@
+# Administrative Docs
+
+The following pages are aimed at game administrators -- the higher-ups that possess shell access and
+are responsible for managing the game.
+
+### Installation and Early Life
+
+- [Choosing (and installing) an SQL Server](./Choosing-An-SQL-Server.md)
+- [Getting Started - Installing Evennia](./Getting-Started.md)
+- [Running Evennia in Docker Containers](./Running-Evennia-in-Docker.md)
+- [Starting, stopping, reloading and resetting Evennia](./Start-Stop-Reload.md)
+- [Keeping your game up to date](./Updating-Your-Game.md)
+ - [Resetting your database](./Updating-Your-Game.md#resetting-your-database)
+- [Making your game available online](./Online-Setup.md)
+ - [Hosting options](./Online-Setup.md#hosting-options)
+ - [Securing your server with SSL/Let's Encrypt](./Online-Setup.md#ssl)
+- [Listing your game](./Evennia-Game-Index.md) at the online [Evennia game
+index](http://games.evennia.com)
+
+### Customizing the server
+
+- [Changing the Settings](./Server-Conf.md#settings-file)
+ - [Available Master
+Settings](https://github.com/evennia/evennia/blob/master/evennia/settings_default.py)
+- [Change Evennia's language](./Internationalization.md) (internationalization)
+- [Apache webserver configuration](./Apache-Config.md) (optional)
+- [Changing text encodings used by the server](./Text-Encodings.md)
+- [The Connection Screen](./Connection-Screen.md)
+- [Guest Logins](./Guest-Logins.md)
+- [How to connect Evennia to IRC channels](./IRC.md)
+- [How to connect Evennia to RSS feeds](./RSS.md)
+- [How to connect Evennia to Grapevine](./Grapevine.md)
+- [How to connect Evennia to Twitter](./How-to-connect-Evennia-to-Twitter.md)
+
+### Administrating the running game
+
+- [Supported clients](./Client-Support-Grid.md) (grid of known client issues)
+- [Changing Permissions](./Building-Permissions.md) of users
+- [Banning](./Banning.md) and deleting users
+ - [Summary of abuse-handling tools](./Banning.md#summary-of-abuse-handling-tools) in the default cmdset
+
+### Working with Evennia
+
+- [Setting up your work environment with version control](./Version-Control.md)
+- [First steps coding with Evennia](./First-Steps-Coding.md)
+- [Setting up a continuous integration build environment](./Continuous-Integration.md)
+
+
+```{toctree}
+ :hidden:
+
+ Choosing-An-SQL-Server
+ Getting-Started
+ Running-Evennia-in-Docker
+ Start-Stop-Reload
+ Updating-Your-Game
+ Online-Setup
+ Evennia-Game-Index
+ Server-Conf
+ Internationalization
+ Apache-Config
+ Text-Encodings
+ Connection-Screen
+ Guest-Logins
+ IRC
+ RSS
+ Grapevine
+ How-to-connect-Evennia-to-Twitter
+ Client-Support-Grid
+ Building-Permissions
+ Banning
+ Version-Control
+ First-Steps-Coding
+ Continuous-Integration
+
+```
\ No newline at end of file
diff --git a/docs/0.9.5/_sources/Apache-Config.md.txt b/docs/0.9.5/_sources/Apache-Config.md.txt
new file mode 100644
index 0000000000..76a21ee24f
--- /dev/null
+++ b/docs/0.9.5/_sources/Apache-Config.md.txt
@@ -0,0 +1,171 @@
+# Apache Config
+
+
+**Warning**: This information is presented as a convenience, using another webserver than Evennia's
+own is not directly supported and you are on your own if you want to do so. Evennia's webserver
+works out of the box without any extra configuration and also runs in-process making sure to avoid
+caching race conditions. The browser web client will most likely not work (at least not without
+tweaking) on a third-party web server.
+
+One reason for wanting to use an external webserver like Apache would be to act as a *proxy* in
+front of the Evennia webserver. Getting this working with TLS (encryption) requires some extra work
+covered at the end of this page.
+
+Note that the Apache instructions below might be outdated. If something is not working right, or you
+use Evennia with a different server, please let us know. Also, if there is a particular Linux distro
+you would like covered, please let us know.
+
+## `mod_wsgi` Setup
+
+### Install `mod_wsgi`
+
+- *Fedora/RHEL* - Apache HTTP Server and `mod_wsgi` are available in the standard package
+repositories for Fedora and RHEL:
+ ```
+ $ dnf install httpd mod_wsgi
+ or
+ $ yum install httpd mod_wsgi
+ ```
+- *Ubuntu/Debian* - Apache HTTP Server and `mod_wsgi` are available in the standard package
+repositories for Ubuntu and Debian:
+ ```
+ $ apt-get update
+ $ apt-get install apache2 libapache2-mod-wsgi
+ ```
+
+### Copy and modify the VHOST
+
+After `mod_wsgi` is installed, copy the `evennia/web/utils/evennia_wsgi_apache.conf` file to your
+apache2 vhosts/sites folder. On Debian/Ubuntu, this is `/etc/apache2/sites-enabled/`. Make your
+modifications **after** copying the file there.
+
+Read the comments and change the paths to point to the appropriate locations within your setup.
+
+### Restart/Reload Apache
+
+You'll then want to reload or restart apache2 after changing the configurations.
+
+- *Fedora/RHEL/Ubuntu*
+ ```
+ $ systemctl restart httpd
+ ```
+- *Ubuntu/Debian*
+ ```
+ $ systemctl restart apache2
+ ```
+
+### Enjoy
+
+With any luck, you'll be able to point your browser at your domain or subdomain that you set up in
+your vhost and see the nifty default Evennia webpage. If not, read the hopefully informative error
+message and work from there. Questions may be directed to our [Evennia Community
+site](http://evennia.com).
+
+### A note on code reloading
+
+If your `mod_wsgi` is set up to run on daemon mode (as will be the case by default on Debian and
+Ubuntu), you may tell `mod_wsgi` to reload by using the `touch` command on
+`evennia/game/web/utils/apache_wsgi.conf`. When `mod_wsgi` sees that the file modification time has
+changed, it will force a code reload. Any modifications to the code will not be propagated to the
+live instance of your site until reloaded.
+
+If you are not running in daemon mode or want to force the issue, simply restart or reload apache2
+to apply your changes.
+
+### Further notes and hints:
+
+If you get strange (and usually uninformative) `Permission denied` errors from Apache, make sure
+that your `evennia` directory is located in a place the webserver may actually access. For example,
+some Linux distributions may default to very restrictive access permissions on a user's `/home`
+directory.
+
+One user commented that they had to add the following to their Apache config to get things to work.
+Not confirmed, but worth trying if there are trouble.
+
+ /evennia/game/web">
+ Options +ExecCGI
+ Allow from all
+
+
+## `mod_proxy` and `mod_ssl` setup
+
+Below are steps on running Evennia using a front-end proxy (Apache HTTP), `mod_proxy_http`,
+`mod_proxy_wstunnel`, and `mod_ssl`. `mod_proxy_http` and `mod_proxy_wstunnel` will simply be
+referred to as
+`mod_proxy` below.
+
+### Install `mod_ssl`
+
+- *Fedora/RHEL* - Apache HTTP Server and `mod_ssl` are available in the standard package
+repositories for Fedora and RHEL:
+ ```
+ $ dnf install httpd mod_ssl
+ or
+ $ yum install httpd mod_ssl
+
+ ```
+- *Ubuntu/Debian* - Apache HTTP Server and `mod_sslj`kl are installed together in the `apache2`
+package and available in the
+standard package repositories for Ubuntu and Debian. `mod_ssl` needs to be enabled after
+installation:
+ ```
+ $ apt-get update
+ $ apt-get install apache2
+ $ a2enmod ssl
+
+ ```
+
+### TLS proxy+websocket configuration
+
+Below is a sample configuration for Evennia with a TLS-enabled http and websocket proxy.
+
+#### Apache HTTP Server Configuration
+
+```
+
+ # Always redirect to https/443
+ ServerName mud.example.com
+ Redirect / https://mud.example.com
+
+
+
+ ServerName mud.example.com
+
+ SSLEngine On
+
+ # Location of certificate and key
+ SSLCertificateFile /etc/pki/tls/certs/mud.example.com.crt
+ SSLCertificateKeyFile /etc/pki/tls/private/mud.example.com.key
+
+ # Use a tool https://www.ssllabs.com/ssltest/ to scan your set after setting up.
+ SSLProtocol TLSv1.2
+ SSLCipherSuite HIGH:!eNULL:!NULL:!aNULL
+
+ # Proxy all websocket traffic to port 4002 in Evennia
+ ProxyPass /ws ws://127.0.0.1:4002/
+ ProxyPassReverse /ws ws://127.0.0.1:4002/
+
+ # Proxy all HTTP traffic to port 4001 in Evennia
+ ProxyPass / http://127.0.0.1:4001/
+ ProxyPassReverse / http://127.0.0.1:4001/
+
+ # Configure separate logging for this Evennia proxy
+ ErrorLog logs/evennia_error.log
+ CustomLog logs/evennia_access.log combined
+
+```
+
+#### Evennia secure websocket configuration
+
+There is a slight trick in setting up Evennia so websocket traffic is handled correctly by the
+proxy. You must set the `WEBSOCKET_CLIENT_URL` setting in your `mymud/server/conf/settings.py` file:
+
+```
+WEBSOCKET_CLIENT_URL = "wss://external.example.com/ws"
+```
+
+The setting above is what the client's browser will actually use. Note the use of `wss://` is
+because our client will be communicating over an encrypted connection ("wss" indicates websocket
+over SSL/TLS). Also, especially note the additional path `/ws` at the end of the URL. This is how
+Apache HTTP Server identifies that a particular request should be proxied to Evennia's websocket
+port but this should be applicable also to other types of proxies (like nginx).
diff --git a/docs/0.9.5/_sources/Arxcode-installing-help.md.txt b/docs/0.9.5/_sources/Arxcode-installing-help.md.txt
new file mode 100644
index 0000000000..e7efdfae28
--- /dev/null
+++ b/docs/0.9.5/_sources/Arxcode-installing-help.md.txt
@@ -0,0 +1,272 @@
+# Arxcode installing help
+
+## Introduction
+
+[Arx - After the Reckoning](http://play.arxmush.org/) is a big and very popular
+[Evennia](http://www.evennia.com)-based game. Arx is heavily roleplaying-centric, relying on game
+masters to drive the story. Technically it's maybe best described as "a MUSH, but with more coded
+systems". In August of 2018, the game's developer, Tehom, generously released the [source code of
+Arx on github](https://github.com/Arx-Game/arxcode). This is a treasure-trove for developers wanting
+to pick ideas or even get a starting game to build on. These instructions are based on the Arx-code
+released as of *Aug 12, 2018*.
+
+If you are not familiar with what Evennia is, you can read
+[an introduction here](./Evennia-Introduction.md).
+
+It's not too hard to run Arx from the sources (of course you'll start with an empty database) but
+since part of Arx has grown organically, it doesn't follow standard Evennia paradigms everywhere.
+This page covers one take on installing and setting things up while making your new Arx-based game
+better match with the vanilla Evennia install.
+
+## Installing Evennia
+
+Firstly, set aside a folder/directory on your drive for everything to follow.
+
+You need to start by installing [Evennia](http://www.evennia.com) by following most of the [Getting
+Started
+Instructions](./Getting-Started.md) for your OS. The difference is that you need to `git clone
+https://github.com/TehomCD/evennia.git` instead of Evennia's repo because Arx uses TehomCD's older
+Evennia 0.8 [fork](https://github.com/TehomCD/evennia), notably still using Python2. This detail is
+important if referring to newer Evennia documentation.
+
+If you are new to Evennia it's *highly* recommended that you run through the
+instructions in full - including initializing and starting a new empty game and connecting to it.
+That way you can be sure Evennia works correctly as a base line. If you have trouble, make sure to
+read the [Troubleshooting instructions](./Getting-Started.md#troubleshooting) for your
+operating system. You can also drop into our
+[forums](https://groups.google.com/forum/#%21forum/evennia), join `#evennia` on `irc.freenode.net`
+or chat from the linked [Discord Server](https://discord.gg/NecFePw).
+
+After installing you should have a `virtualenv` running and you should have the following file
+structure in your set-aside folder:
+
+```
+vienv/
+evennia/
+mygame/
+
+```
+
+Here `mygame` is the empty game you created during the Evennia install, with `evennia --init`. Go to
+that and run `evennia stop` to make sure your empty game is not running. We'll instead let Evenna
+run Arx, so in principle you could erase `mygame` - but it could also be good to have a clean game
+to compare to.
+
+## Installing Arxcode
+
+### Clone the arxcode repo
+
+Cd to the root of your directory and clone the released source code from github:
+
+ git clone https://github.com/Arx-Game/arxcode.git myarx
+
+A new folder `myarx` should appear next to the ones you already had. You could rename this to
+something else if you want.
+
+Cd into `myarx`. If you wonder about the structure of the game dir, you can [read more about it
+here](./Directory-Overview.md).
+
+### Clean up settings
+
+Arx has split evennia's normal settings into `base_settings.py` and `production_settings.py`. It
+also has its own solution for managing 'secret' parts of the settings file. We'll keep most of Arx
+way but remove the secret-handling and replace it with the normal Evennia method.
+
+Cd into `myarx/server/conf/` and open the file `settings.py` in a text editor. The top part (within
+`"""..."""`) is just help text. Wipe everything underneath that and make it look like this instead
+(don't forget to save):
+
+```
+from base_settings import *
+
+TELNET_PORTS = [4000]
+SERVERNAME = "MyArx"
+GAME_SLOGAN = "The cool game"
+
+try:
+ from server.conf.secret_settings import *
+except ImportError:
+ print("secret_settings.py file not found or failed to import.")
+```
+
+> Note: Indents and capitalization matter in Python. Make indents 4 spaces (not tabs) for your own
+> sanity. If you want a starter on Python in Evennia, [you can look here](Python-basic-
+introduction).
+
+This will import Arx' base settings and override them with the Evennia-default telnet port and give
+the game a name. The slogan changes the sub-text shown under the name of your game in the website
+header. You can tweak these to your own liking later.
+
+Next, create a new, empty file `secret_settings.py` in the same location as the `settings.py` file.
+This can just contain the following:
+
+```python
+SECRET_KEY = "sefsefiwwj3 jnwidufhjw4545_oifej whewiu hwejfpoiwjrpw09&4er43233fwefwfw"
+
+```
+
+Replace the long random string with random ASCII characters of your own. The secret key should not
+be shared.
+
+Next, open `myarx/server/conf/base_settings.py` in your text editor. We want to remove/comment out
+all mentions of the `decouple` package, which Evennia doesn't use (we use `private_settings.py` to
+hide away settings that should not be shared).
+
+Comment out `from decouple import config` by adding a `#` to the start of the line: `# from decouple
+import config`. Then search for `config(` in the file and comment out all lines where this is used.
+Many of these are specific to the server environment where the original Arx runs, so is not that
+relevant to us.
+
+### Install Arx dependencies
+
+Arx has some further dependencies beyond vanilla Evennia. Start by `cd`:ing to the root of your
+`myarx` folder.
+
+> If you run *Linux* or *Mac*: Edit `myarx/requirements.txt` and comment out the line
+> `pypiwin32==219` - it's only needed on Windows and will give an error on other platforms.
+
+Make sure your `virtualenv` is active, then run
+
+ pip install -r requirements.txt
+
+The needed Python packages will be installed for you.
+
+### Adding logs/ folder
+
+The Arx repo does not contain the `myarx/server/logs/` folder Evennia expects for storing server
+logs. This is simple to add:
+
+ # linux/mac
+ mkdir server/logs
+ # windows
+ mkdir server\logs
+
+### Setting up the database and starting
+
+From the `myarx` folder, run
+
+ evennia migrate
+
+This creates the database and will step through all database migrations needed.
+
+ evennia start
+
+If all goes well Evennia will now start up, running Arx! You can connect to it on `localhost` (or
+`127.0.0.1` if your platform doesn't alias `localhost`), port `4000` using a Telnet client.
+Alternatively, you can use your web browser to browse to `http://localhost:4001` to see the game's
+website and get to the web client.
+
+When you log in you'll get the standard Evennia greeting (since the database is empty), but you can
+try `help` to see that it's indeed Arx that is running.
+
+### Additional Setup Steps
+
+The first time you start Evennia after creating the database with the `evennia migrate` step above,
+it should create a few starting objects for you - your superuser account, which it will prompt you
+to enter, a starting room (Limbo), and a character object for you. If for some reason this does not
+occur, you may have to follow the steps below. For the first time Superuser login you may have to
+run steps 7-8 and 10 to create and connect to your in-came Character.
+
+1. Login to the game website with your Superuser account.
+2. Press the `Admin` button to get into the (Django-) Admin Interface.
+3. Navigate to the `Accounts` section.
+4. Add a new Account named for the new staffer. Use a place holder password and dummy e-mail
+ address.
+5. Flag account as `Staff` and apply the `Admin` permission group (This assumes you have already set
+ up an Admin Group in Django).
+6. Add Tags named `player` and `developer`.
+7. Log into the game using the web client (or a third-party telnet client) using your superuser
+ account. Move to where you want the new staffer character to appear.
+8. In the game client, run `@create/drop :typeclasses.characters.Character`, where
+ `` is usually the same name you used for the Staffer account you created in the
+ Admin earlier (if you are creating a Character for your superuser, use your superuser account
+name).
+ This creates a new in-game Character and places it in your current location.
+9. Have the new Admin player log into the game.
+10. Have the new Admin puppet the character with `@ic StafferName`.
+11. Have the new Admin change their password - `@password = `.
+
+Now that you have a Character and an Account object, there's a few additional things you may need to
+do in order for some commands to function properly. You can either execute these as in-game commands
+while `@ic` (controlling your character object).
+
+1. `@py from web.character.models import RosterEntry;RosterEntry.objects.create(player=self.player,
+character=self)`
+2. `@py from world.dominion.models import PlayerOrNpc, AssetOwner;dompc =
+PlayerOrNpc.objects.create(player = self.player);AssetOwner.objects.create(player=dompc)`
+
+Those steps will give you a 'RosterEntry', 'PlayerOrNpc', and 'AssetOwner' objects. RosterEntry
+explicitly connects a character and account object together, even while offline, and contains
+additional information about a character's current presence in game (such as which 'roster' they're
+in, if you choose to use an active roster of characters). PlayerOrNpc are more character extensions,
+as well as support for npcs with no in-game presence and just represented by a name which can be
+offscreen members of a character's family. It also allows for membership in Organizations.
+AssetOwner holds information about a character or organization's money and resources.
+
+## Alternate guide by Pax for installing on Windows
+
+If for some reason you cannot use the Windows Subsystem for Linux (which would use instructions
+identical to the ones above), it's possible to get Evennia running under Anaconda for Windows. The
+process is a little bit trickier.
+
+ Make sure you have:
+ * Git for Windows https://git-scm.com/download/win
+ * Anaconda for Windows https://www.anaconda.com/distribution/
+ * VC++ Compiler for Python 2.7 http://aka.ms/vcpython27
+
+conda update conda
+conda create -n arx python=2.7
+source activate arx
+
+ Set up a convenient repository place for things.
+
+cd ~
+mkdir Source
+cd Source
+mkdir Arx
+cd Arx
+
+ Replace the SSH git clone links below with your own github forks.
+ If you don't plan to change Evennia at all, you can use the
+ evennia/evennia.git repo instead of a forked one.
+
+git clone git@github.com:/evennia.git
+git clone git@github.com:/arxcode.git
+
+ Evennia is a package itself, so we want to install it and all of its
+ prerequisites, after switching to the appropriately-tagged branch for
+ Arxcode.
+
+cd evennia
+git checkout tags/v0.7 -b arx-master
+pip install -e .
+
+ Arx has some dependencies of its own, so now we'll go install them
+ As it is not a package, we'll use the normal requirements file.
+
+cd ../arxcode
+pip install -r requirements.txt
+
+ The git repo doesn't include the empty log directory and Evennia is unhappy if you
+ don't have it, so while still in the arxcode directory...
+
+mkdir server/logs
+
+ Now hit https://github.com/evennia/evennia/wiki/Arxcode-installing-help and
+ change the setup stuff as in the 'Clean up settings' section.
+
+ Then we will create our default database...
+
+../evennia/bin/windows/evennia.bat migrate
+
+ ...and do the first run. You need winpty because Windows does not have a TTY/PTY
+ by default, and so the Python console input commands (used for prompts on first
+ run) will fail and you will end up in an unhappy place. Future runs, you should
+ not need winpty.
+
+winpty ../evennia/bin/windows/evennia.bat start
+
+ Once this is done, you should have your Evennia server running Arxcode up
+ on localhost at port 4000, and the webserver at http://localhost:4001/
+
+ And you are done! Huzzah!
\ No newline at end of file
diff --git a/docs/0.9.5/_sources/Async-Process.md.txt b/docs/0.9.5/_sources/Async-Process.md.txt
new file mode 100644
index 0000000000..e44e9330a2
--- /dev/null
+++ b/docs/0.9.5/_sources/Async-Process.md.txt
@@ -0,0 +1,233 @@
+# Async Process
+
+
+*This is considered an advanced topic.*
+
+## Synchronous versus Asynchronous
+
+Most program code operates *synchronously*. This means that each statement in your code gets
+processed and finishes before the next can begin. This makes for easy-to-understand code. It is also
+a *requirement* in many cases - a subsequent piece of code often depend on something calculated or
+defined in a previous statement.
+
+Consider this piece of code in a traditional Python program:
+
+```python
+ print("before call ...")
+ long_running_function()
+ print("after call ...")
+
+```
+
+When run, this will print `"before call ..."`, after which the `long_running_function` gets to work
+for however long time. Only once that is done, the system prints `"after call ..."`. Easy and
+logical to follow. Most of Evennia work in this way and often it's important that commands get
+executed in the same strict order they were coded.
+
+Evennia, via Twisted, is a single-process multi-user server. In simple terms this means that it
+swiftly switches between dealing with player input so quickly that each player feels like they do
+things at the same time. This is a clever illusion however: If one user, say, runs a command
+containing that `long_running_function`, *all* other players are effectively forced to wait until it
+finishes.
+
+Now, it should be said that on a modern computer system this is rarely an issue. Very few commands
+run so long that other users notice it. And as mentioned, most of the time you *want* to enforce
+all commands to occur in strict sequence.
+
+When delays do become noticeable and you don't care in which order the command actually completes,
+you can run it *asynchronously*. This makes use of the `run_async()` function in
+`src/utils/utils.py`:
+
+```python
+ run_async(function, *args, **kwargs)
+```
+
+Where `function` will be called asynchronously with `*args` and `**kwargs`. Example:
+
+```python
+ from evennia import utils
+ print("before call ...")
+ utils.run_async(long_running_function)
+ print("after call ...")
+```
+
+Now, when running this you will find that the program will not wait around for
+`long_running_function` to finish. In fact you will see `"before call ..."` and `"after call ..."`
+printed out right away. The long-running function will run in the background and you (and other
+users) can go on as normal.
+
+## Customizing asynchronous operation
+
+A complication with using asynchronous calls is what to do with the result from that call. What if
+`long_running_function` returns a value that you need? It makes no real sense to put any lines of
+code after the call to try to deal with the result from `long_running_function` above - as we saw
+the `"after call ..."` got printed long before `long_running_function` was finished, making that
+line quite pointless for processing any data from the function. Instead one has to use *callbacks*.
+
+`utils.run_async` takes reserved kwargs that won't be passed into the long-running function:
+
+- `at_return(r)` (the *callback*) is called when the asynchronous function (`long_running_function`
+ above) finishes successfully. The argument `r` will then be the return value of that function (or
+ `None`).
+
+ ```python
+ def at_return(r):
+ print(r)
+ ```
+
+- `at_return_kwargs` - an optional dictionary that will be fed as keyword arguments to the
+`at_return` callback.
+- `at_err(e)` (the *errback*) is called if the asynchronous function fails and raises an exception.
+ This exception is passed to the errback wrapped in a *Failure* object `e`. If you do not supply an
+ errback of your own, Evennia will automatically add one that silently writes errors to the evennia
+ log. An example of an errback is found below:
+
+```python
+ def at_err(e):
+ print("There was an error:", str(e))
+```
+
+- `at_err_kwargs` - an optional dictionary that will be fed as keyword arguments to the `at_err`
+ errback.
+
+An example of making an asynchronous call from inside a [Command](./Commands.md) definition:
+
+```python
+ from evennia import utils, Command
+
+ class CmdAsync(Command):
+
+ key = "asynccommand"
+
+ def func(self):
+
+ def long_running_function():
+ #[... lots of time-consuming code ...]
+ return final_value
+
+ def at_return_function(r):
+ self.caller.msg("The final value is %s" % r)
+
+ def at_err_function(e):
+ self.caller.msg("There was an error: %s" % e)
+
+ # do the async call, setting all callbacks
+ utils.run_async(long_running_function, at_return=at_return_function,
+at_err=at_err_function)
+```
+
+That's it - from here on we can forget about `long_running_function` and go on with what else need
+to be done. *Whenever* it finishes, the `at_return_function` function will be called and the final
+value will
+pop up for us to see. If not we will see an error message.
+
+## delay
+
+The `delay` function is a much simpler sibling to `run_async`. It is in fact just a way to delay the
+execution of a command until a future time. This is equivalent to something like `time.sleep()`
+except delay is asynchronous while `sleep` would lock the entire server for the duration of the
+sleep.
+
+```python
+ from evennia.utils import delay
+
+ # [...]
+ # e.g. inside a Command, where `self.caller` is available
+ def callback(obj):
+ obj.msg("Returning!")
+ delay(10, callback, self.caller)
+```
+
+This will delay the execution of the callback for 10 seconds. This function is explored much more in
+the [Command Duration Tutorial](./Command-Duration.md).
+
+You can also try the following snippet just see how it works:
+
+ @py from evennia.utils import delay; delay(10, lambda who: who.msg("Test!"), self)
+
+Wait 10 seconds and 'Test!' should be echoed back to you.
+
+
+## The @interactive decorator
+
+As of Evennia 0.9, the `@interactive` [decorator](https://realpython.com/primer-on-python-decorators/)
+is available. This makes any function or method possible to 'pause' and/or await player input
+in an interactive way.
+
+```python
+ from evennia.utils import interactive
+
+ @interactive
+ def myfunc(caller):
+
+ while True:
+ caller.msg("Getting ready to wait ...")
+ yield(5)
+ caller.msg("Now 5 seconds have passed.")
+
+ response = yield("Do you want to wait another 5 secs?")
+
+ if response.lower() not in ("yes", "y"):
+ break
+```
+
+The `@interactive` decorator gives the function the ability to pause. The use
+of `yield(seconds)` will do just that - it will asynchronously pause for the
+number of seconds given before continuing. This is technically equivalent to
+using `call_async` with a callback that continues after 5 secs. But the code
+with `@interactive` is a little easier to follow.
+
+Within the `@interactive` function, the `response = yield("question")` question
+allows you to ask the user for input. You can then process the input, just like
+you would if you used the Python `input` function. There is one caveat to this
+functionality though - _it will only work if the function/method has an
+argument named exactly `caller`_. This is because internally Evennia will look
+for the `caller` argument and treat that as the source of input.
+
+All of this makes the `@interactive` decorator very useful. But it comes with a
+few caveats. Notably, decorating a function/method with `@interactive` turns it
+into a Python [generator](https://wiki.python.org/moin/Generators). The most
+common issue is that you cannot use `return ` from a generator (just an
+empty `return` works). To return a value from a function/method you have decorated
+with `@interactive`, you must instead use a special Twisted function
+`twisted.internet.defer.returnValue`. Evennia also makes this function
+conveniently available from `evennia.utils`:
+
+```python
+ from evennia.utils import interactive, returnValue
+
+ @interactive
+ def myfunc():
+
+ # ...
+ result = 10
+
+ # this must be used instead of `return result`
+ returnValue(result)
+
+```
+
+
+
+## Assorted notes
+
+Overall, be careful with choosing when to use asynchronous calls. It is mainly useful for large
+administration operations that have no direct influence on the game world (imports and backup
+operations come to mind). Since there is no telling exactly when an asynchronous call actually ends,
+using them for in-game commands is to potentially invite confusion and inconsistencies (and very
+hard-to-reproduce bugs).
+
+The very first synchronous example above is not *really* correct in the case of Twisted, which is
+inherently an asynchronous server. Notably you might find that you will *not* see the first `before
+call ...` text being printed out right away. Instead all texts could end up being delayed until
+after the long-running process finishes. So all commands will retain their relative order as
+expected, but they may appear with delays or in groups.
+
+## Further reading
+
+Technically, `run_async` is just a very thin and simplified wrapper around a
+[Twisted Deferred](http://twistedmatrix.com/documents/9.0.0/core/howto/defer.html) object; the
+wrapper sets
+up a default errback also if none is supplied. If you know what you are doing there is nothing
+stopping you from bypassing the utility function, building a more sophisticated callback chain after
+your own liking.
diff --git a/docs/0.9.5/_sources/Attributes.md.txt b/docs/0.9.5/_sources/Attributes.md.txt
new file mode 100644
index 0000000000..b04d18f654
--- /dev/null
+++ b/docs/0.9.5/_sources/Attributes.md.txt
@@ -0,0 +1,393 @@
+# Attributes
+
+
+When performing actions in Evennia it is often important that you store data for later. If you write
+a menu system, you have to keep track of the current location in the menu tree so that the player
+can give correct subsequent commands. If you are writing a combat system, you might have a
+combattant's next roll get easier dependent on if their opponent failed. Your characters will
+probably need to store roleplaying-attributes like strength and agility. And so on.
+
+[Typeclassed](./Typeclasses.md) game entities ([Accounts](./Accounts.md), [Objects](./Objects.md),
+[Scripts](./Scripts.md) and [Channels](./Communications.md)) always have *Attributes* associated with them.
+Attributes are used to store any type of data 'on' such entities. This is different from storing
+data in properties already defined on entities (such as `key` or `location`) - these have very
+specific names and require very specific types of data (for example you couldn't assign a python
+*list* to the `key` property no matter how hard you tried). `Attributes` come into play when you
+want to assign arbitrary data to arbitrary names.
+
+**Attributes are _not_ secure by default and any player may be able to change them unless you
+[prevent this behavior](./Attributes.md#locking-and-checking-attributes).**
+
+## The .db and .ndb shortcuts
+
+To save persistent data on a Typeclassed object you normally use the `db` (DataBase) operator. Let's
+try to save some data to a *Rose* (an [Object](./Objects.md)):
+
+```python
+ # saving
+ rose.db.has_thorns = True
+ # getting it back
+ is_ouch = rose.db.has_thorns
+
+```
+
+This looks like any normal Python assignment, but that `db` makes sure that an *Attribute* is
+created behind the scenes and is stored in the database. Your rose will continue to have thorns
+throughout the life of the server now, until you deliberately remove them.
+
+To be sure to save **non-persistently**, i.e. to make sure NOT to create a database entry, you use
+`ndb` (NonDataBase). It works in the same way:
+
+```python
+ # saving
+ rose.ndb.has_thorns = True
+ # getting it back
+ is_ouch = rose.ndb.has_thorns
+```
+
+Technically, `ndb` has nothing to do with `Attributes`, despite how similar they look. No
+`Attribute` object is created behind the scenes when using `ndb`. In fact the database is not
+invoked at all since we are not interested in persistence. There is however an important reason to
+use `ndb` to store data rather than to just store variables direct on entities - `ndb`-stored data
+is tracked by the server and will not be purged in various cache-cleanup operations Evennia may do
+while it runs. Data stored on `ndb` (as well as `db`) will also be easily listed by example the
+`@examine` command.
+
+You can also `del` properties on `db` and `ndb` as normal. This will for example delete an
+`Attribute`:
+
+```python
+ del rose.db.has_thorns
+```
+
+Both `db` and `ndb` defaults to offering an `all` property on themselves. This returns all
+associated attributes or non-persistent properties.
+
+```python
+ list_of_all_rose_attributes = rose.db.all
+ list_of_all_rose_ndb_attrs = rose.ndb.all
+```
+
+If you use `all` as the name of an attribute, this will be used instead. Later deleting your custom
+`all` will return the default behaviour.
+
+## The AttributeHandler
+
+The `.db` and `.ndb` properties are very convenient but if you don't know the name of the Attribute
+beforehand they cannot be used. Behind the scenes `.db` actually accesses the `AttributeHandler`
+which sits on typeclassed entities as the `.attributes` property. `.ndb` does the same for the
+`.nattributes` property.
+
+The handlers have normal access methods that allow you to manage and retrieve `Attributes` and
+`NAttributes`:
+
+- `has('attrname')` - this checks if the object has an Attribute with this key. This is equivalent
+ to doing `obj.db.attrname`.
+- `get(...)` - this retrieves the given Attribute. Normally the `value` property of the Attribute is
+ returned, but the method takes keywords for returning the Attribute object itself. By supplying an
+ `accessing_object` to the call one can also make sure to check permissions before modifying
+ anything.
+- `add(...)` - this adds a new Attribute to the object. An optional [lockstring](./Locks.md) can be
+ supplied here to restrict future access and also the call itself may be checked against locks.
+- `remove(...)` - Remove the given Attribute. This can optionally be made to check for permission
+ before performing the deletion. - `clear(...)` - removes all Attributes from object.
+- `all(...)` - returns all Attributes (of the given category) attached to this object.
+
+See [this section](./Attributes.md#locking-and-checking-attributes) for more about locking down Attribute
+access and editing. The `Nattribute` offers no concept of access control.
+
+Some examples:
+
+```python
+ import evennia
+ obj = evennia.search_object("MyObject")
+
+ obj.attributes.add("test", "testvalue")
+ print(obj.db.test) # prints "testvalue"
+ print(obj.attributes.get("test")) # "
+ print(obj.attributes.all()) # prints []
+ obj.attributes.remove("test")
+```
+
+
+## Properties of Attributes
+
+An Attribute object is stored in the database. It has the following properties:
+
+- `key` - the name of the Attribute. When doing e.g. `obj.db.attrname = value`, this property is set
+ to `attrname`.
+- `value` - this is the value of the Attribute. This value can be anything which can be pickled -
+ objects, lists, numbers or what have you (see
+ [this section](./Attributes.md#what-types-of-data-can-i-save-in-an-attribute) for more info). In the
+example
+ `obj.db.attrname = value`, the `value` is stored here.
+- `category` - this is an optional property that is set to None for most Attributes. Setting this
+ allows to use Attributes for different functionality. This is usually not needed unless you want
+ to use Attributes for very different functionality ([Nicks](./Nicks.md) is an example of using
+Attributes
+ in this way). To modify this property you need to use the [Attribute
+Handler](#the-attributehandler).
+- `strvalue` - this is a separate value field that only accepts strings. This severely limits the
+ data possible to store, but allows for easier database lookups. This property is usually not used
+ except when re-using Attributes for some other purpose ([Nicks](./Nicks.md) use it). It is only
+ accessible via the [Attribute Handler](./Attributes.md#the-attributehandler).
+
+There are also two special properties:
+
+- `attrtype` - this is used internally by Evennia to separate [Nicks](./Nicks.md), from Attributes (Nicks
+ use Attributes behind the scenes).
+- `model` - this is a *natural-key* describing the model this Attribute is attached to. This is on
+ the form *appname.modelclass*, like `objects.objectdb`. It is used by the Attribute and
+ NickHandler to quickly sort matches in the database. Neither this nor `attrtype` should normally
+ need to be modified.
+
+Non-database attributes have no equivalence to `category` nor `strvalue`, `attrtype` or `model`.
+
+## Persistent vs non-persistent
+
+So *persistent* data means that your data will survive a server reboot, whereas with
+*non-persistent* data it will not ...
+
+... So why would you ever want to use non-persistent data? The answer is, you don't have to. Most of
+the time you really want to save as much as you possibly can. Non-persistent data is potentially
+useful in a few situations though.
+
+- You are worried about database performance. Since Evennia caches Attributes very aggressively,
+ this is not an issue unless you are reading *and* writing to your Attribute very often (like many
+ times per second). Reading from an already cached Attribute is as fast as reading any Python
+ property. But even then this is not likely something to worry about: Apart from Evennia's own
+ caching, modern database systems themselves also cache data very efficiently for speed. Our
+default
+ database even runs completely in RAM if possible, alleviating much of the need to write to disk
+ during heavy loads.
+- A more valid reason for using non-persistent data is if you *want* to lose your state when logging
+ off. Maybe you are storing throw-away data that are re-initialized at server startup. Maybe you
+ are implementing some caching of your own. Or maybe you are testing a buggy [Script](./Scripts.md) that
+ does potentially harmful stuff to your character object. With non-persistent storage you can be
+sure
+ that whatever is messed up, it's nothing a server reboot can't clear up.
+- NAttributes have no restrictions at all on what they can store (see next section), since they
+ don't need to worry about being saved to the database - they work very well for temporary storage.
+- You want to implement a fully or partly *non-persistent world*. Who are we to argue with your
+ grand vision!
+
+## What types of data can I save in an Attribute?
+
+> None of the following affects NAttributes, which does not invoke the database at all. There are no
+> restrictions to what can be stored in a NAttribute.
+
+The database doesn't know anything about Python objects, so Evennia must *serialize* Attribute
+values into a string representation in order to store it to the database. This is done using the
+`pickle` module of Python (the only exception is if you use the `strattr` keyword of the
+AttributeHandler to save to the `strvalue` field of the Attribute. In that case you can only save
+*strings* which will not be pickled).
+
+It's important to note that when you access the data in an Attribute you are *always* de-serializing
+it from the database representation every time. This is because we allow for storing
+database-entities in Attributes too. If we cached it as its Python form, we might end up with
+situations where the database entity was deleted since we last accessed the Attribute.
+De-serializing data with a database-entity in it means querying the database for that object and
+making sure it still exists (otherwise it will be set to `None`). Performance-wise this is usually
+not a big deal. But if you are accessing the Attribute as part of some big loop or doing a large
+amount of reads/writes you should first extract it to a temporary variable, operate on *that* and
+then save the result back to the Attribute. If you are storing a more complex structure like a
+`dict` or a `list` you should make sure to "disconnect" it from the database before looping over it,
+as mentioned in the [Retrieving Mutable Objects](./Attributes.md#retrieving-mutable-objects) section
+below.
+
+### Storing single objects
+
+With a single object, we mean anything that is *not iterable*, like numbers, strings or custom class
+instances without the `__iter__` method.
+
+* You can generally store any non-iterable Python entity that can be
+ [pickled](http://docs.python.org/library/pickle.html).
+* Single database objects/typeclasses can be stored as any other in the Attribute. These can
+ normally *not* be pickled, but Evennia will behind the scenes convert them to an internal
+ representation using their classname, database-id and creation-date with a microsecond precision,
+ guaranteeing you get the same object back when you access the Attribute later.
+* If you *hide* a database object inside a non-iterable custom class (like stored as a variable
+ inside it), Evennia will not know it's there and won't convert it safely. Storing classes with
+ such hidden database objects is *not* supported and will lead to errors!
+
+```python
+# Examples of valid single-value attribute data:
+obj.db.test1 = 23
+obj.db.test1 = False
+# a database object (will be stored as an internal representation)
+obj.db.test2 = myobj
+
+# example of an invalid, "hidden" dbobject
+class Invalid(object):
+ def __init__(self, dbobj):
+ # no way for Evennia to know this is a dbobj
+ self.dbobj = dbobj
+invalid = Invalid(myobj)
+obj.db.invalid = invalid # will cause error!
+```
+
+### Storing multiple objects
+
+This means storing objects in a collection of some kind and are examples of *iterables*, pickle-able
+entities you can loop over in a for-loop. Attribute-saving supports the following iterables:
+
+* [Tuples](https://docs.python.org/2/library/functions.html#tuple), like `(1,2,"test", )`.
+* [Lists](https://docs.python.org/2/tutorial/datastructures.html#more-on-lists), like `[1,2,"test",
+]`.
+* [Dicts](https://docs.python.org/2/tutorial/datastructures.html#dictionaries), like `{1:2,
+"test":]`.
+* [Sets](https://docs.python.org/2/tutorial/datastructures.html#sets), like `{1,2,"test",}`.
+* [collections.OrderedDict](https://docs.python.org/2/library/collections.html#collections.OrderedDict), like `OrderedDict((1,2), ("test", ))`.
+* [collections.Deque](https://docs.python.org/2/library/collections.html#collections.deque), like
+`deque((1,2,"test",))`.
+* *Nestings* of any combinations of the above, like lists in dicts or an OrderedDict of tuples, each
+containing dicts, etc.
+* All other iterables (i.e. entities with the `__iter__` method) will be converted to a *list*.
+ Since you can use any combination of the above iterables, this is generally not much of a
+ limitation.
+
+Any entity listed in the [Single object](./Attributes.md#storing-single-objects) section above can be
+stored in the iterable.
+
+> As mentioned in the previous section, database entities (aka typeclasses) are not possible to
+> pickle. So when storing an iterable, Evennia must recursively traverse the iterable *and all its
+> nested sub-iterables* in order to find eventual database objects to convert. This is a very fast
+> process but for efficiency you may want to avoid too deeply nested structures if you can.
+
+```python
+# examples of valid iterables to store
+obj.db.test3 = [obj1, 45, obj2, 67]
+# a dictionary
+obj.db.test4 = {'str':34, 'dex':56, 'agi':22, 'int':77}
+# a mixed dictionary/list
+obj.db.test5 = {'members': [obj1,obj2,obj3], 'enemies':[obj4,obj5]}
+# a tuple with a list in it
+obj.db.test6 = (1,3,4,8, ["test", "test2"], 9)
+# a set
+obj.db.test7 = set([1,2,3,4,5])
+# in-situ manipulation
+obj.db.test8 = [1,2,{"test":1}]
+obj.db.test8[0] = 4
+obj.db.test8[2]["test"] = 5
+# test8 is now [4,2,{"test":5}]
+```
+
+### Retrieving Mutable objects
+
+A side effect of the way Evennia stores Attributes is that *mutable* iterables (iterables that can
+be modified in-place after they were created, which is everything except tuples) are handled by
+custom objects called `_SaverList`, `_SaverDict` etc. These `_Saver...` classes behave just like the
+normal variant except that they are aware of the database and saves to it whenever new data gets
+assigned to them. This is what allows you to do things like `self.db.mylist[7] = val` and be sure
+that the new version of list is saved. Without this you would have to load the list into a temporary
+variable, change it and then re-assign it to the Attribute in order for it to save.
+
+There is however an important thing to remember. If you retrieve your mutable iterable into another
+variable, e.g. `mylist2 = obj.db.mylist`, your new variable (`mylist2`) will *still* be a
+`_SaverList`. This means it will continue to save itself to the database whenever it is updated!
+
+
+```python
+ obj.db.mylist = [1,2,3,4]
+ mylist = obj.db.mylist
+ mylist[3] = 5 # this will also update database
+ print(mylist) # this is now [1,2,3,5]
+ print(obj.db.mylist) # this is also [1,2,3,5]
+```
+
+To "disconnect" your extracted mutable variable from the database you simply need to convert the
+`_Saver...` iterable to a normal Python structure. So to convert a `_SaverList`, you use the
+`list()` function, for a `_SaverDict` you use `dict()` and so on.
+
+```python
+ obj.db.mylist = [1,2,3,4]
+ mylist = list(obj.db.mylist) # convert to normal list
+ mylist[3] = 5
+ print(mylist) # this is now [1,2,3,5]
+ print(obj.db.mylist) # this is still [1,2,3,4]
+```
+
+A further problem comes with *nested mutables*, like a dict containing lists of dicts or something
+like that. Each of these nested mutables would be `_Saver*` structures connected to the database and
+disconnecting the outermost one of them would not disconnect those nested within. To make really
+sure you disonnect a nested structure entirely from the database, Evennia provides a special
+function `evennia.utils.dbserialize.deserialize`:
+
+```
+from evennia.utils.dbserialize import deserialize
+
+decoupled_mutables = deserialize(nested_mutables)
+
+```
+
+The result of this operation will be a structure only consisting of normal Python mutables (`list`
+instead of `_SaverList` and so on).
+
+
+Remember, this is only valid for *mutable* iterables.
+[Immutable](http://en.wikipedia.org/wiki/Immutable) objects (strings, numbers, tuples etc) are
+already disconnected from the database from the onset.
+
+```python
+ obj.db.mytup = (1,2,[3,4])
+ obj.db.mytup[0] = 5 # this fails since tuples are immutable
+
+ # this works but will NOT update database since outermost is a tuple
+ obj.db.mytup[2][1] = 5
+ print(obj.db.mytup[2][1]) # this still returns 4, not 5
+
+ mytup1 = obj.db.mytup # mytup1 is already disconnected from database since outermost
+ # iterable is a tuple, so we can edit the internal list as we want
+ # without affecting the database.
+```
+
+> Attributes will fetch data fresh from the database whenever you read them, so
+> if you are performing big operations on a mutable Attribute property (such as looping over a list
+> or dict) you should make sure to "disconnect" the Attribute's value first and operate on this
+> rather than on the Attribute. You can gain dramatic speed improvements to big loops this
+> way.
+
+
+## Locking and checking Attributes
+
+Attributes are normally not locked down by default, but you can easily change that for individual
+Attributes (like those that may be game-sensitive in games with user-level building).
+
+First you need to set a *lock string* on your Attribute. Lock strings are specified [Locks](./Locks.md).
+The relevant lock types are
+
+- `attrread` - limits who may read the value of the Attribute
+- `attredit` - limits who may set/change this Attribute
+
+You cannot use the `db` handler to modify Attribute object (such as setting a lock on them) - The
+`db` handler will return the Attribute's *value*, not the Attribute object itself. Instead you use
+the AttributeHandler and set it to return the object instead of the value:
+
+```python
+ lockstring = "attread:all();attredit:perm(Admins)"
+ obj.attributes.get("myattr", return_obj=True).locks.add(lockstring)
+```
+
+Note the `return_obj` keyword which makes sure to return the `Attribute` object so its LockHandler
+could be accessed.
+
+A lock is no good if nothing checks it -- and by default Evennia does not check locks on Attributes.
+You have to add a check to your commands/code wherever it fits (such as before setting an
+Attribute).
+
+```python
+ # in some command code where we want to limit
+ # setting of a given attribute name on an object
+ attr = obj.attributes.get(attrname,
+ return_obj=True,
+ accessing_obj=caller,
+ default=None,
+ default_access=False)
+ if not attr:
+ caller.msg("You cannot edit that Attribute!")
+ return
+ # edit the Attribute here
+```
+
+The same keywords are available to use with `obj.attributes.set()` and `obj.attributes.remove()`,
+those will check for the `attredit` lock type.
diff --git a/docs/0.9.5/_sources/Banning.md.txt b/docs/0.9.5/_sources/Banning.md.txt
new file mode 100644
index 0000000000..09baae2b95
--- /dev/null
+++ b/docs/0.9.5/_sources/Banning.md.txt
@@ -0,0 +1,148 @@
+# Banning
+
+
+Whether due to abuse, blatant breaking of your rules, or some other reason, you will eventually find
+no other recourse but to kick out a particularly troublesome player. The default command set has
+admin tools to handle this, primarily `ban`, `unban`, and `boot`.
+
+## Creating a ban
+
+Say we have a troublesome player "YouSuck" - this is a person that refuses common courtesy - an
+abusive
+and spammy account that is clearly created by some bored internet hooligan only to cause grief. You
+have tried to be nice. Now you just want this troll gone.
+
+### Name ban
+
+The easiest recourse is to block the account YouSuck from ever connecting again.
+
+ ban YouSuck
+
+This will lock the name YouSuck (as well as 'yousuck' and any other capitalization combination), and
+next time they try to log in with this name the server will not let them!
+
+You can also give a reason so you remember later why this was a good thing (the banned account will
+never see this)
+
+ ban YouSuck:This is just a troll.
+
+If you are sure this is just a spam account, you might even consider deleting the player account
+outright:
+
+ account/delete YouSuck
+
+Generally, banning the name is the easier and safer way to stop the use of an account -- if you
+change your mind you can always remove the block later whereas a deletion is permanent.
+
+### IP ban
+
+Just because you block YouSuck's name might not mean the trolling human behind that account gives
+up. They can just create a new account YouSuckMore and be back at it. One way to make things harder
+for them is to tell the server to not allow connections from their particular IP address.
+
+First, when the offending account is online, check which IP address they use. This you can do with
+the `who` command, which will show you something like this:
+
+ Account Name On for Idle Room Cmds Host
+ YouSuckMore 01:12 2m 22 212 237.333.0.223
+
+The "Host" bit is the IP address from which the account is connecting. Use this to define the ban
+instead of the name:
+
+ ban 237.333.0.223
+
+This will stop YouSuckMore connecting from their computer. Note however that IP address might change
+easily - either due to how the player's Internet Service Provider operates or by the user simply
+changing computers. You can make a more general ban by putting asterisks `*` as wildcards for the
+groups of three digits in the address. So if you figure out that !YouSuckMore mainly connects from
+237.333.0.223, 237.333.0.225, and 237.333.0.256 (only changes in their subnet), it might be an idea
+to put down a ban like this to include any number in that subnet:
+
+ ban 237.333.0.*
+
+You should combine the IP ban with a name-ban too of course, so the account YouSuckMore is truly
+locked regardless of where they connect from.
+
+Be careful with too general IP bans however (more asterisks above). If you are unlucky you could be
+blocking out innocent players who just happen to connect from the same subnet as the offender.
+
+## Booting
+
+YouSuck is not really noticing all this banning yet though - and won't until having logged out and
+trying to log back in again. Let's help the troll along.
+
+ boot YouSuck
+
+Good riddance. You can give a reason for booting too (to be echoed to the player before getting
+kicked out).
+
+ boot YouSuck:Go troll somewhere else.
+
+### Lifting a ban
+
+Use the `unban` (or `ban`) command without any arguments and you will see a list of all currently
+active bans:
+
+ Active bans
+ id name/ip date reason
+ 1 yousuck Fri Jan 3 23:00:22 2020 This is just a Troll.
+ 2 237.333.0.* Fri Jan 3 23:01:03 2020 YouSuck's IP.
+
+Use the `id` from this list to find out which ban to lift.
+
+ unban 2
+
+ Cleared ban 2: 237.333.0.*
+
+## Summary of abuse-handling tools
+
+Below are other useful commands for dealing with annoying players.
+
+- **who** -- (as admin) Find the IP of a account. Note that one account can be connected to from
+multiple IPs depending on what you allow in your settings.
+- **examine/account thomas** -- Get all details about an account. You can also use `*thomas` to get
+the account. If not given, you will get the *Object* thomas if it exists in the same location, which
+is not what you want in this case.
+- **boot thomas** -- Boot all sessions of the given account name.
+- **boot 23** -- Boot one specific client session/IP by its unique id.
+- **ban** -- List all bans (listed with ids)
+- **ban thomas** -- Ban the user with the given account name
+- **ban/ip `134.233.2.111`** -- Ban by IP
+- **ban/ip `134.233.2.*`** -- Widen IP ban
+- **ban/ip `134.233.*.*`** -- Even wider IP ban
+- **unban 34** -- Remove ban with id #34
+
+- **cboot mychannel = thomas** -- Boot a subscriber from a channel you control
+- **clock mychannel = control:perm(Admin);listen:all();send:all()** -- Fine control of access to
+your channel using [lock definitions](./Locks.md).
+
+Locking a specific command (like `page`) is accomplished like so:
+1. Examine the source of the command. [The default `page` command class](
+https://github.com/evennia/evennia/blob/master/evennia/commands/default/comms.py#L686) has the lock
+string **"cmd:not pperm(page_banned)"**. This means that unless the player has the 'permission'
+"page_banned" they can use this command. You can assign any lock string to allow finer customization
+in your commands. You might look for the value of an [Attribute](./Attributes.md) or [Tag](./Tags.md), your
+current location etc.
+2. **perm/account thomas = page_banned** -- Give the account the 'permission' which causes (in this
+case) the lock to fail.
+
+- **perm/del/account thomas = page_banned** -- Remove the given permission
+
+- **tel thomas = jail** -- Teleport a player to a specified location or #dbref
+- **type thomas = FlowerPot** -- Turn an annoying player into a flower pot (assuming you have a
+`FlowerPot` typeclass ready)
+- **userpassword thomas = fooBarFoo** -- Change a user's password
+- **account/delete thomas** -- Delete a player account (not recommended, use **ban** instead)
+
+- **server** -- Show server statistics, such as CPU load, memory usage, and how many objects are
+cached
+- **time** -- Gives server uptime, runtime, etc
+- **reload** -- Reloads the server without disconnecting anyone
+- **reset** -- Restarts the server, kicking all connections
+- **shutdown** -- Stops the server cold without it auto-starting again
+- **py** -- Executes raw Python code, allows for direct inspection of the database and account
+objects on the fly. For advanced users.
+
+
+**Useful Tip:** `evennia changepassword ` entered into the command prompt will reset the
+password of any account, including the superuser or admin accounts. This is a feature of Django.
diff --git a/docs/0.9.5/_sources/Batch-Code-Processor.md.txt b/docs/0.9.5/_sources/Batch-Code-Processor.md.txt
new file mode 100644
index 0000000000..df8eaff1ff
--- /dev/null
+++ b/docs/0.9.5/_sources/Batch-Code-Processor.md.txt
@@ -0,0 +1,229 @@
+# Batch Code Processor
+
+
+For an introduction and motivation to using batch processors, see [here](./Batch-Processors.md). This
+page describes the Batch-*code* processor. The Batch-*command* one is covered [here](Batch-Command-
+Processor).
+
+## Basic Usage
+
+The batch-code processor is a superuser-only function, invoked by
+
+ > @batchcode path.to.batchcodefile
+
+Where `path.to.batchcodefile` is the path to a *batch-code file*. Such a file should have a name
+ending in "`.py`" (but you shouldn't include that in the path). The path is given like a python path
+relative to a folder you define to hold your batch files, set by `BATCH_IMPORT_PATH` in your
+settings. Default folder is (assuming your game is called "mygame") `mygame/world/`. So if you want
+to run the example batch file in `mygame/world/batch_code.py`, you could simply use
+
+ > @batchcode batch_code
+
+This will try to run through the entire batch file in one go. For more gradual, *interactive*
+control you can use the `/interactive` switch. The switch `/debug` will put the processor in
+*debug* mode. Read below for more info.
+
+## The batch file
+
+A batch-code file is a normal Python file. The difference is that since the batch processor loads
+and executes the file rather than importing it, you can reliably update the file, then call it
+again, over and over and see your changes without needing to `@reload` the server. This makes for
+easy testing. In the batch-code file you have also access to the following global variables:
+
+- `caller` - This is a reference to the object running the batchprocessor.
+- `DEBUG` - This is a boolean that lets you determine if this file is currently being run in debug-
+mode or not. See below how this can be useful.
+
+Running a plain Python file through the processor will just execute the file from beginning to end.
+If you want to get more control over the execution you can use the processor's *interactive* mode.
+This runs certain code blocks on their own, rerunning only that part until you are happy with it. In
+order to do this you need to add special markers to your file to divide it up into smaller chunks.
+These take the form of comments, so the file remains valid Python.
+
+Here are the rules of syntax of the batch-code `*.py` file.
+
+- `#CODE` as the first on a line marks the start of a *code* block. It will last until the beginning
+of another marker or the end of the file. Code blocks contain functional python code. Each `#CODE`
+block will be run in complete isolation from other parts of the file, so make sure it's self-
+contained.
+- `#HEADER` as the first on a line marks the start of a *header* block. It lasts until the next
+marker or the end of the file. This is intended to hold imports and variables you will need for all
+other blocks .All python code defined in a header block will always be inserted at the top of every
+`#CODE` blocks in the file. You may have more than one `#HEADER` block, but that is equivalent to
+having one big one. Note that you can't exchange data between code blocks, so editing a header-
+variable in one code block won't affect that variable in any other code block!
+- `#INSERT path.to.file` will insert another batchcode (Python) file at that position.
+- A `#` that is not starting a `#HEADER`, `#CODE` or `#INSERT` instruction is considered a comment.
+- Inside a block, normal Python syntax rules apply. For the sake of indentation, each block acts as
+a separate python module.
+
+Below is a version of the example file found in `evennia/contrib/tutorial_examples/`.
+
+```python
+ #
+ # This is an example batch-code build file for Evennia.
+ #
+
+ #HEADER
+
+ # This will be included in all other #CODE blocks
+
+ from evennia import create_object, search_object
+ from evennia.contrib.tutorial_examples import red_button
+ from typeclasses.objects import Object
+
+ limbo = search_object('Limbo')[0]
+
+
+ #CODE
+
+ red_button = create_object(red_button.RedButton, key="Red button",
+ location=limbo, aliases=["button"])
+
+ # caller points to the one running the script
+ caller.msg("A red button was created.")
+
+ # importing more code from another batch-code file
+ #INSERT batch_code_insert
+
+ #CODE
+
+ table = create_object(Object, key="Blue Table", location=limbo)
+ chair = create_object(Object, key="Blue Chair", location=limbo)
+
+ string = "A %s and %s were created."
+ if DEBUG:
+ table.delete()
+ chair.delete()
+ string += " Since debug was active, " \
+ "they were deleted again."
+ caller.msg(string % (table, chair))
+```
+
+This uses Evennia's Python API to create three objects in sequence.
+
+## Debug mode
+
+Try to run the example script with
+
+ > @batchcode/debug tutorial_examples.example_batch_code
+
+The batch script will run to the end and tell you it completed. You will also get messages that the
+button and the two pieces of furniture were created. Look around and you should see the button
+there. But you won't see any chair nor a table! This is because we ran this with the `/debug`
+switch, which is directly visible as `DEBUG==True` inside the script. In the above example we
+handled this state by deleting the chair and table again.
+
+The debug mode is intended to be used when you test out a batchscript. Maybe you are looking for
+bugs in your code or try to see if things behave as they should. Running the script over and over
+would then create an ever-growing stack of chairs and tables, all with the same name. You would have
+to go back and painstakingly delete them later.
+
+## Interactive mode
+
+Interactive mode works very similar to the [batch-command processor counterpart](Batch-Command-
+Processor). It allows you more step-wise control over how the batch file is executed. This is useful
+for debugging or for picking and choosing only particular blocks to run. Use `@batchcode` with the
+`/interactive` flag to enter interactive mode.
+
+ > @batchcode/interactive tutorial_examples.example_batch_code
+
+You should see the following:
+
+ 01/02: red_button = create_object(red_button.RedButton, [...] (hh for help)
+
+This shows that you are on the first `#CODE` block, the first of only two commands in this batch
+file. Observe that the block has *not* actually been executed at this point!
+
+To take a look at the full code snippet you are about to run, use `ll` (a batch-processor version of
+`look`).
+
+```python
+ from evennia.utils import create, search
+ from evennia.contrib.tutorial_examples import red_button
+ from typeclasses.objects import Object
+
+ limbo = search.objects(caller, 'Limbo', global_search=True)[0]
+
+ red_button = create.create_object(red_button.RedButton, key="Red button",
+ location=limbo, aliases=["button"])
+
+ # caller points to the one running the script
+ caller.msg("A red button was created.")
+```
+
+Compare with the example code given earlier. Notice how the content of `#HEADER` has been pasted at
+the top of the `#CODE` block. Use `pp` to actually execute this block (this will create the button
+and give you a message). Use `nn` (next) to go to the next command. Use `hh` for a list of commands.
+
+If there are tracebacks, fix them in the batch file, then use `rr` to reload the file. You will
+still be at the same code block and can rerun it easily with `pp` as needed. This makes for a simple
+debug cycle. It also allows you to rerun individual troublesome blocks - as mentioned, in a large
+batch file this can be very useful (don't forget the `/debug` mode either).
+
+Use `nn` and `bb` (next and back) to step through the file; e.g. `nn 12` will jump 12 steps forward
+(without processing any blocks in between). All normal commands of Evennia should work too while
+working in interactive mode.
+
+## Limitations and Caveats
+
+The batch-code processor is by far the most flexible way to build a world in Evennia. There are
+however some caveats you need to keep in mind.
+
+### Safety
+Or rather the lack of it. There is a reason only *superusers* are allowed to run the batch-code
+processor by default. The code-processor runs **without any Evennia security checks** and allows
+full access to Python. If an untrusted party could run the code-processor they could execute
+arbitrary python code on your machine, which is potentially a very dangerous thing. If you want to
+allow other users to access the batch-code processor you should make sure to run Evennia as a
+separate and very limited-access user on your machine (i.e. in a 'jail'). By comparison, the batch-
+command processor is much safer since the user running it is still 'inside' the game and can't
+really do anything outside what the game commands allow them to.
+
+### No communication between code blocks
+Global variables won't work in code batch files, each block is executed as stand-alone environments.
+`#HEADER` blocks are literally pasted on top of each `#CODE` block so updating some header-variable
+in your block will not make that change available in another block. Whereas a python execution
+limitation, allowing this would also lead to very hard-to-debug code when using the interactive mode
+- this would be a classical example of "spaghetti code".
+
+The main practical issue with this is when building e.g. a room in one code block and later want to
+connect that room with a room you built in the current block. There are two ways to do this:
+
+- Perform a database search for the name of the room you created (since you cannot know in advance
+which dbref it got assigned). The problem is that a name may not be unique (you may have a lot of "A
+dark forest" rooms). There is an easy way to handle this though - use [Tags](./Tags.md) or *Aliases*. You
+can assign any number of tags and/or aliases to any object. Make sure that one of those tags or
+aliases is unique to the room (like "room56") and you will henceforth be able to always uniquely
+search and find it later.
+- Use the `caller` global property as an inter-block storage. For example, you could have a
+dictionary of room references in an `ndb`:
+ ```python
+ #HEADER
+ if caller.ndb.all_rooms is None:
+ caller.ndb.all_rooms = {}
+
+ #CODE
+ # create and store the castle
+ castle = create_object("rooms.Room", key="Castle")
+ caller.ndb.all_rooms["castle"] = castle
+
+ #CODE
+ # in another node we want to access the castle
+ castle = caller.ndb.all_rooms.get("castle")
+ ```
+Note how we check in `#HEADER` if `caller.ndb.all_rooms` doesn't already exist before creating the
+dict. Remember that `#HEADER` is copied in front of every `#CODE` block. Without that `if` statement
+we'd be wiping the dict every block!
+
+### Don't treat a batchcode file like any Python file
+Despite being a valid Python file, a batchcode file should *only* be run by the batchcode processor.
+You should not do things like define Typeclasses or Commands in them, or import them into other
+code. Importing a module in Python will execute base level of the module, which in the case of your
+average batchcode file could mean creating a lot of new objects every time.
+### Don't let code rely on the batch-file's real file path
+
+When you import things into your batchcode file, don't use relative imports but always import with
+paths starting from the root of your game directory or evennia library. Code that relies on the
+batch file's "actual" location *will fail*. Batch code files are read as text and the strings
+executed. When the code runs it has no knowledge of what file those strings where once a part of.
diff --git a/docs/0.9.5/_sources/Batch-Command-Processor.md.txt b/docs/0.9.5/_sources/Batch-Command-Processor.md.txt
new file mode 100644
index 0000000000..9278b4be7f
--- /dev/null
+++ b/docs/0.9.5/_sources/Batch-Command-Processor.md.txt
@@ -0,0 +1,182 @@
+# Batch Command Processor
+
+
+For an introduction and motivation to using batch processors, see [here](./Batch-Processors.md). This
+page describes the Batch-*command* processor. The Batch-*code* one is covered [here](Batch-Code-
+Processor).
+
+## Basic Usage
+
+The batch-command processor is a superuser-only function, invoked by
+
+ > @batchcommand path.to.batchcmdfile
+
+Where `path.to.batchcmdfile` is the path to a *batch-command file* with the "`.ev`" file ending.
+This path is given like a python path relative to a folder you define to hold your batch files, set
+with `BATCH_IMPORT_PATH` in your settings. Default folder is (assuming your game is in the `mygame`
+folder) `mygame/world`. So if you want to run the example batch file in
+`mygame/world/batch_cmds.ev`, you could use
+
+ > @batchcommand batch_cmds
+
+A batch-command file contains a list of Evennia in-game commands separated by comments. The
+processor will run the batch file from beginning to end. Note that *it will not stop if commands in
+it fail* (there is no universal way for the processor to know what a failure looks like for all
+different commands). So keep a close watch on the output, or use *Interactive mode* (see below) to
+run the file in a more controlled, gradual manner.
+
+## The batch file
+
+The batch file is a simple plain-text file containing Evennia commands. Just like you would write
+them in-game, except you have more freedom with line breaks.
+
+Here are the rules of syntax of an `*.ev` file. You'll find it's really, really simple:
+
+- All lines having the `#` (hash)-symbol *as the first one on the line* are considered *comments*.
+All non-comment lines are treated as a command and/or their arguments.
+- Comment lines have an actual function -- they mark the *end of the previous command definition*.
+So never put two commands directly after one another in the file - separate them with a comment, or
+the second of the two will be considered an argument to the first one. Besides, using plenty of
+comments is good practice anyway.
+- A line that starts with the word `#INSERT` is a comment line but also signifies a special
+instruction. The syntax is `#INSERT ` and tries to import a given batch-cmd file
+into this one. The inserted batch file (file ending `.ev`) will run normally from the point of the
+`#INSERT` instruction.
+- Extra whitespace in a command definition is *ignored*. - A completely empty line translates in to
+a line break in texts. Two empty lines thus means a new paragraph (this is obviously only relevant
+for commands accepting such formatting, such as the `@desc` command).
+- The very last command in the file is not required to end with a comment.
+- You *cannot* nest another `@batchcommand` statement into your batch file. If you want to link many
+batch-files together, use the `#INSERT` batch instruction instead. You also cannot launch the
+`@batchcode` command from your batch file, the two batch processors are not compatible.
+
+Below is a version of the example file found in `evennia/contrib/tutorial_examples/batch_cmds.ev`.
+
+```bash
+ #
+ # This is an example batch build file for Evennia.
+ #
+
+ # This creates a red button
+ @create button:tutorial_examples.red_button.RedButton
+ # (This comment ends input for @create)
+ # Next command. Let's create something.
+ @set button/desc =
+ This is a large red button. Now and then
+ it flashes in an evil, yet strangely tantalizing way.
+
+ A big sign sits next to it. It says:
+
+
+ -----------
+
+ Press me!
+
+ -----------
+
+
+ ... It really begs to be pressed! You
+ know you want to!
+
+ # This inserts the commands from another batch-cmd file named
+ # batch_insert_file.ev.
+ #INSERT examples.batch_insert_file
+
+
+ # (This ends the @set command). Note that single line breaks
+ # and extra whitespace in the argument are ignored. Empty lines
+ # translate into line breaks in the output.
+ # Now let's place the button where it belongs (let's say limbo #2 is
+ # the evil lair in our example)
+ @teleport #2
+ # (This comments ends the @teleport command.)
+ # Now we drop it so others can see it.
+ # The very last command in the file needs not be ended with #.
+ drop button
+```
+
+To test this, run `@batchcommand` on the file:
+
+ > @batchcommand contrib.tutorial_examples.batch_cmds
+
+A button will be created, described and dropped in Limbo. All commands will be executed by the user
+calling the command.
+
+> Note that if you interact with the button, you might find that its description changes, loosing
+your custom-set description above. This is just the way this particular object works.
+
+## Interactive mode
+
+Interactive mode allows you to more step-wise control over how the batch file is executed. This is
+useful for debugging and also if you have a large batch file and is only updating a small part of it
+-- running the entire file again would be a waste of time (and in the case of `@create`-ing objects
+you would to end up with multiple copies of same-named objects, for example). Use `@batchcommand`
+with the `/interactive` flag to enter interactive mode.
+
+ > @batchcommand/interactive tutorial_examples.batch_cmds
+
+You will see this:
+
+ 01/04: @create button:tutorial_examples.red_button.RedButton (hh for help)
+
+This shows that you are on the `@create` command, the first out of only four commands in this batch
+file. Observe that the command `@create` has *not* been actually processed at this point!
+
+To take a look at the full command you are about to run, use `ll` (a batch-processor version of
+`look`). Use `pp` to actually process the current command (this will actually `@create` the button)
+-- and make sure it worked as planned. Use `nn` (next) to go to the next command. Use `hh` for a
+list of commands.
+
+If there are errors, fix them in the batch file, then use `rr` to reload the file. You will still be
+at the same command and can rerun it easily with `pp` as needed. This makes for a simple debug
+cycle. It also allows you to rerun individual troublesome commands - as mentioned, in a large batch
+file this can be very useful. Do note that in many cases, commands depend on the previous ones (e.g.
+if `@create` in the example above had failed, the following commands would have had nothing to
+operate on).
+
+Use `nn` and `bb` (next and back) to step through the file; e.g. `nn 12` will jump 12 steps forward
+(without processing any command in between). All normal commands of Evennia should work too while
+working in interactive mode.
+
+## Limitations and Caveats
+
+The batch-command processor is great for automating smaller builds or for testing new commands and
+objects repeatedly without having to write so much. There are several caveats you have to be aware
+of when using the batch-command processor for building larger, complex worlds though.
+
+The main issue is that when you run a batch-command script you (*you*, as in your superuser
+character) are actually moving around in the game creating and building rooms in sequence, just as
+if you had been entering those commands manually, one by one. You have to take this into account
+when creating the file, so that you can 'walk' (or teleport) to the right places in order.
+
+This also means there are several pitfalls when designing and adding certain types of objects. Here
+are some examples:
+
+- *Rooms that change your [Command Set](./Command-Sets.md)*: Imagine that you build a 'dark' room, which
+severely limits the cmdsets of those entering it (maybe you have to find the light switch to
+proceed). In your batch script you would create this room, then teleport to it - and promptly be
+shifted into the dark state where none of your normal build commands work ...
+- *Auto-teleportation*: Rooms that automatically teleport those that enter them to another place
+(like a trap room, for example). You would be teleported away too.
+- *Mobiles*: If you add aggressive mobs, they might attack you, drawing you into combat. If they
+have AI they might even follow you around when building - or they might move away from you before
+you've had time to finish describing and equipping them!
+
+The solution to all these is to plan ahead. Make sure that superusers are never affected by whatever
+effects are in play. Add an on/off switch to objects and make sure it's always set to *off* upon
+creation. It's all doable, one just needs to keep it in mind.
+
+## Assorted notes
+
+The fact that you build as 'yourself' can also be considered an advantage however, should you ever
+decide to change the default command to allow others than superusers to call the processor. Since
+normal access-checks are still performed, a malevolent builder with access to the processor should
+not be able to do all that much damage (this is the main drawback of the [Batch Code
+Processor](./Batch-Code-Processor.md))
+
+- [GNU Emacs](https://www.gnu.org/software/emacs/) users might find it interesting to use emacs'
+*evennia mode*. This is an Emacs major mode found in `evennia/utils/evennia-mode.el`. It offers
+correct syntax highlighting and indentation with `` when editing `.ev` files in Emacs. See the
+header of that file for installation instructions.
+- [VIM](http://www.vim.org/) users can use amfl's [vim-evennia](https://github.com/amfl/vim-evennia)
+mode instead, see its readme for install instructions.
\ No newline at end of file
diff --git a/docs/0.9.5/_sources/Batch-Processors.md.txt b/docs/0.9.5/_sources/Batch-Processors.md.txt
new file mode 100644
index 0000000000..85a4c8a30f
--- /dev/null
+++ b/docs/0.9.5/_sources/Batch-Processors.md.txt
@@ -0,0 +1,82 @@
+# Batch Processors
+
+
+Building a game world is a lot of work, especially when starting out. Rooms should be created,
+descriptions have to be written, objects must be detailed and placed in their proper places. In many
+traditional MUD setups you had to do all this online, line by line, over a telnet session.
+
+Evennia already moves away from much of this by shifting the main coding work to external Python
+modules. But also building would be helped if one could do some or all of it externally. Enter
+Evennia's *batch processors* (there are two of them). The processors allows you, as a game admin, to
+build your game completely offline in normal text files (*batch files*) that the processors
+understands. Then, when you are ready, you use the processors to read it all into Evennia (and into
+the database) in one go.
+
+You can of course still build completely online should you want to - this is certainly the easiest
+way to go when learning and for small build projects. But for major building work, the advantages of
+using the batch-processors are many:
+- It's hard to compete with the comfort of a modern desktop text editor; Compared to a traditional
+MUD line input, you can get much better overview and many more features. Also, accidentally pressing
+Return won't immediately commit things to the database.
+- You might run external spell checkers on your batch files. In the case of one of the batch-
+processors (the one that deals with Python code), you could also run external debuggers and code
+analyzers on your file to catch problems before feeding it to Evennia.
+- The batch files (as long as you keep them) are records of your work. They make a natural starting
+point for quickly re-building your world should you ever decide to start over.
+- If you are an Evennia developer, using a batch file is a fast way to setup a test-game after
+having reset the database.
+- The batch files might come in useful should you ever decide to distribute all or part of your
+world to others.
+
+
+There are two batch processors, the Batch-*command* processor and the Batch-*code* processor. The
+first one is the simpler of the two. It doesn't require any programming knowledge - you basically
+just list in-game commands in a text file. The code-processor on the other hand is much more
+powerful but also more complex - it lets you use Evennia's API to code your world in full-fledged
+Python code.
+
+- The [Batch Command Processor](./Batch-Command-Processor.md)
+- The [Batch Code Processor](./Batch-Code-Processor.md)
+
+If you plan to use international characters in your batchfiles you are wise to read about *file
+encodings* below.
+
+## A note on File Encodings
+
+As mentioned, both the processors take text files as input and then proceed to process them. As long
+as you stick to the standard [ASCII](http://en.wikipedia.org/wiki/Ascii) character set (which means
+the normal English characters, basically) you should not have to worry much about this section.
+
+Many languages however use characters outside the simple `ASCII` table. Common examples are various
+apostrophes and umlauts but also completely different symbols like those of the greek or cyrillic
+alphabets.
+
+First, we should make it clear that Evennia itself handles international characters just fine. It
+(and Django) uses [unicode](http://en.wikipedia.org/wiki/Unicode) strings internally.
+
+The problem is that when reading a text file like the batchfile, we need to know how to decode the
+byte-data stored therein to universal unicode. That means we need an *encoding* (a mapping) for how
+the file stores its data. There are many, many byte-encodings used around the world, with opaque
+names such as `Latin-1`, `ISO-8859-3` or `ARMSCII-8` to pick just a few examples. Problem is that
+it's practially impossible to determine which encoding was used to save a file just by looking at it
+(it's just a bunch of bytes!). You have to *know*.
+
+With this little introduction it should be clear that Evennia can't guess but has to *assume* an
+encoding when trying to load a batchfile. The text editor and Evennia must speak the same "language"
+so to speak. Evennia will by default first try the international `UTF-8` encoding, but you can have
+Evennia try any sequence of different encodings by customizing the `ENCODINGS` list in your settings
+file. Evennia will use the first encoding in the list that do not raise any errors. Only if none
+work will the server give up and return an error message.
+
+You can often change the text editor encoding (this depends on your editor though), otherwise you
+need to add the editor's encoding to Evennia's `ENCODINGS` list. If you are unsure, write a test
+file with lots of non-ASCII letters in the editor of your choice, then import to make sure it works
+as it should.
+
+More help with encodings can be found in the entry [Text Encodings](./Text-Encodings.md) and also in the
+Wikipedia article [here](http://en.wikipedia.org/wiki/Text_encodings).
+
+**A footnote for the batch-code processor**: Just because *Evennia* can parse your file and your
+fancy special characters, doesn't mean that *Python* allows their use. Python syntax only allows
+international characters inside *strings*. In all other source code only `ASCII` set characters are
+allowed.
diff --git a/docs/0.9.5/_sources/Bootstrap-&-Evennia.md.txt b/docs/0.9.5/_sources/Bootstrap-&-Evennia.md.txt
new file mode 100644
index 0000000000..fd7db78909
--- /dev/null
+++ b/docs/0.9.5/_sources/Bootstrap-&-Evennia.md.txt
@@ -0,0 +1,100 @@
+# Bootstrap & Evennia
+
+# What is Bootstrap?
+Evennia's new default web page uses a framework called [Bootstrap](https://getbootstrap.com/). This
+framework is in use across the internet - you'll probably start to recognize its influence once you
+learn some of the common design patterns. This switch is great for web developers, perhaps like
+yourself, because instead of wondering about setting up different grid systems or what custom class
+another designer used, we have a base, a bootstrap, to work from. Bootstrap is responsive by
+default, and comes with some default styles that Evennia has lightly overrode to keep some of the
+same colors and styles you're used to from the previous design.
+
+For your reading pleasure, a brief overview of Bootstrap follows. For more in-depth info, please
+read [the documentation](https://getbootstrap.com/docs/4.0/getting-started/introduction/).
+***
+
+## The Layout System
+Other than the basic styling Bootstrap includes, it also includes [a built in layout and grid
+system](https://getbootstrap.com/docs/4.0/layout/overview/).
+The first part of this system is [the
+container](https://getbootstrap.com/docs/4.0/layout/overview/#containers).
+
+The container is meant to hold all your page content. Bootstrap provides two types: fixed-width and
+full-width.
+Fixed-width containers take up a certain max-width of the page - they're useful for limiting the
+width on Desktop or Tablet platforms, instead of making the content span the width of the page.
+```
+
+
+
+```
+Full width containers take up the maximum width available to them - they'll span across a wide-
+screen desktop or a smaller screen phone, edge-to-edge.
+```
+
+
+
+```
+
+The second part of the layout system is [the grid](https://getbootstrap.com/docs/4.0/layout/grid/).
+This is the bread-and-butter of the layout of Bootstrap - it allows you to change the size of
+elements depending on the size of the screen, without writing any media queries. We'll briefly go
+over it - to learn more, please read the docs or look at the source code for Evennia's home page in
+your browser.
+> Important! Grid elements should be in a .container or .container-fluid. This will center the
+contents of your site.
+
+Bootstrap's grid system allows you to create rows and columns by applying classes based on
+breakpoints. The default breakpoints are extra small, small, medium, large, and extra-large. If
+you'd like to know more about these breakpoints, please [take a look at the documentation for
+them.](https://getbootstrap.com/docs/4.0/layout/overview/#responsive-breakpoints)
+
+To use the grid system, first create a container for your content, then add your rows and columns
+like so:
+```
+
+
+
+ 1 of 3
+
+
+ 2 of 3
+
+
+ 3 of 3
+
+
+
+```
+This layout would create three equal-width columns.
+
+To specify your sizes - for instance, Evennia's default site has three columns on desktop and
+tablet, but reflows to single-column on smaller screens. Try it out!
+```
+
+
+
+ 1 of 4
+
+
+ 2 of 4
+
+
+ 3 of 4
+
+
+ 4 of 4
+
+
+
+```
+This layout would be 4 columns on large screens, 2 columns on medium screens, and 1 column on
+anything smaller.
+
+To learn more about Bootstrap's grid, please [take a look at the
+docs](https://getbootstrap.com/docs/4.0/layout/grid/)
+***
+
+## More Bootstrap
+Bootstrap also provides a huge amount of utilities, as well as styling and content elements. To
+learn more about them, please [read the Bootstrap docs](https://getbootstrap.com/docs/4.0/getting-started/introduction/) or read one of our other web tutorials.
diff --git a/docs/0.9.5/_sources/Bootstrap-Components-and-Utilities.md.txt b/docs/0.9.5/_sources/Bootstrap-Components-and-Utilities.md.txt
new file mode 100644
index 0000000000..5e46964766
--- /dev/null
+++ b/docs/0.9.5/_sources/Bootstrap-Components-and-Utilities.md.txt
@@ -0,0 +1,82 @@
+# Bootstrap Components and Utilities
+
+Bootstrap provides many utilities and components you can use when customizing Evennia's web
+presence. We'll go over a few examples here that you might find useful.
+> Please take a look at either [the basic web tutorial](./Add-a-simple-new-web-page.md) or [the web
+character view tutorial](./Web-Character-View-Tutorial.md)
+> to get a feel for how to add pages to Evennia's website to test these examples.
+
+## General Styling
+Bootstrap provides base styles for your site. These can be customized through CSS, but the default
+styles are intended to provide a consistent, clean look for sites.
+
+### Color
+Most elements can be styled with default colors. [Take a look at the
+documentation](https://getbootstrap.com/docs/4.0/utilities/colors/) to learn more about these colors
+- suffice to say, adding a class of text-* or bg-*, for instance, text-primary, sets the text color
+or background color.
+
+### Borders
+Simply adding a class of 'border' to an element adds a border to the element. For more in-depth
+info, please [read the documentation on
+borders.](https://getbootstrap.com/docs/4.0/utilities/borders/).
+```
+
+```
+You can also easily round corners just by adding a class.
+```
+
+```
+
+### Spacing
+Bootstrap provides classes to easily add responsive margin and padding. Most of the time, you might
+like to add margins or padding through CSS itself - however these classes are used in the default
+Evennia site. [Take a look at the docs](https://getbootstrap.com/docs/4.0/utilities/spacing/) to
+learn more.
+
+***
+## Components
+
+### Buttons
+[Buttons](https://getbootstrap.com/docs/4.0/components/buttons/) in Bootstrap are very easy to use -
+button styling can be added to `