Resolve django 1.11 migration errors.

This commit is contained in:
Griatch 2017-06-17 22:15:00 +02:00
commit 0ff1718437
54 changed files with 6444 additions and 574 deletions

View file

@ -10,6 +10,6 @@ before_script:
- cd dummy
- evennia migrate
script:
- coverage run --source=../evennia --omit=*/migrations/*,*/urls.py,*/test*.py ../bin/unix/evennia test evennia
- coverage run --source=../evennia --omit=*/migrations/*,*/urls.py,*/test*.py,*.sh,*.txt,*.md,*.pyc,*.service ../bin/unix/evennia test evennia
after_success:
- coveralls

View file

@ -38,6 +38,9 @@ from weakref import WeakValueDictionary
from traceback import format_exc
from itertools import chain
from copy import copy
import types
from twisted.internet import reactor
from twisted.internet.task import deferLater
from twisted.internet.defer import inlineCallbacks, returnValue
from django.conf import settings
from evennia.comms.channelhandler import CHANNELHANDLER
@ -127,6 +130,12 @@ _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.
@ -134,8 +143,8 @@ def _msg_err(receiver, stringtuple):
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.
_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})."
@ -151,12 +160,77 @@ def _msg_err(receiver, stringtuple):
errmsg=stringtuple[1].strip(),
timestamp=timestamp).strip())
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
if not _GET_INPUT:
from evennia.utils.evmenu import get_input as _GET_INPUT
try:
if response is None:
value = generator.next()
else:
value = generator.send(response)
except StopIteration:
pass
else:
if isinstance(value, (int, float)):
utils.delay(value, _progressive_cmd_run, cmd, generator)
elif isinstance(value, basestring):
_GET_INPUT(cmd.caller, value, _process_input, cmd=cmd, generator=generator)
else:
raise ValueError("unknown type for a yielded value in command: {}".format(type(value)))
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, Player or Session): the caller.
prompt (basestring): The sent prompt.
result (basestring): 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)
return False
# custom Exceptions
class NoCmdSets(Exception):
"No cmdsets found. Critical error."
pass
class ExecSystemCommand(Exception):
"Run a system command"
def __init__(self, syscmd, sysarg):
@ -510,7 +584,13 @@ def cmdhandler(called_by, raw_string, _testing=False, callertype="session", sess
# main command code
# (return value is normally None)
ret = yield cmd.func()
ret = cmd.func()
if isinstance(ret, types.GeneratorType):
# cmd.func() is a generator, execute progressively
_progressive_cmd_run(cmd, ret)
yield None
else:
ret = yield ret
# post-command hook
yield cmd.at_post_cmd()
@ -672,3 +752,4 @@ def cmdhandler(called_by, raw_string, _testing=False, callertype="session", sess
except Exception:
# This catches exceptions in cmdhandler exceptions themselves
_msg_err(error_to, _ERROR_CMDHANDLER)

View file

@ -429,7 +429,7 @@ class Command(with_metaclass(CommandMeta, object)):
object, conventionally with a preceding space.
"""
if hasattr(self, 'obj') and self.obj != caller:
if hasattr(self, 'obj') and self.obj and self.obj != caller:
return " (%s)" % self.obj.get_display_name(caller).strip()
return ""

View file

@ -1926,6 +1926,10 @@ class CmdExamine(ObjManipCommand):
string += "\n|wLocation|n: %s" % obj.location
if obj.location:
string += " (#%s)" % obj.location.id
if hasattr(obj, "home"):
string += "\n|wHome|n: %s" % obj.home
if obj.home:
string += " (#%s)" % obj.home.id
if hasattr(obj, "destination") and obj.destination:
string += "\n|wDestination|n: %s" % obj.destination
if obj.destination:
@ -2240,7 +2244,7 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
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.
reference. A puppeted object cannot be moved to None.
Teleports an object somewhere. If no object is given, you yourself
is teleported to the target location. """
@ -2265,19 +2269,21 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
# teleporting to None
if not args:
obj_to_teleport = caller
caller.msg("Teleported to None-location.")
if caller.location and not tel_quietly:
caller.location.msg_contents("%s teleported into nothingness." % caller, exclude=caller)
else:
obj_to_teleport = caller.search(lhs, global_search=True)
if not obj_to_teleport:
caller.msg("Did not find object to teleport.")
return
caller.msg("Teleported %s -> None-location." % obj_to_teleport)
if obj_to_teleport.location and not tel_quietly:
obj_to_teleport.location.msg_contents("%s teleported %s into nothingness."
% (caller, obj_to_teleport),
exclude=caller)
if obj_to_teleport.has_player:
caller.msg("Cannot teleport a puppeted object "
"(%s, puppeted by %s) to a None-location." % (
obj_to_teleport.key, obj_to_teleport.player))
return
caller.msg("Teleported %s -> None-location." % obj_to_teleport)
if obj_to_teleport.location and not tel_quietly:
obj_to_teleport.location.msg_contents("%s teleported %s into nothingness."
% (caller, obj_to_teleport),
exclude=caller)
obj_to_teleport.location=None
return

View file

@ -560,6 +560,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
"CLIENTNAME": utils.to_str,
"ENCODING": validate_encoding,
"MCCP": validate_bool,
"NOGOAHEAD": validate_bool,
"MXP": validate_bool,
"NOCOLOR": validate_bool,
"NOPKEEPALIVE": validate_bool,

View file

@ -2,7 +2,6 @@
Base typeclass for in-game Channels.
"""
from evennia.typeclasses.models import TypeclassBase
from evennia.comms.models import TempMsg, ChannelDB
from evennia.comms.managers import ChannelManager
@ -267,7 +266,11 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
"""
# get all players or objects connected to this channel and send to them
for entity in self.subscriptions.all():
if online:
subs = self.subscriptions.online()
else:
subs = self.subscriptions.all()
for entity in subs:
# if the entity is muted, we don't send them a message
if entity in self.mutelist:
continue

View file

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-06-06 17:31
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comms', '0010_auto_20161206_1912'),
]
operations = [
migrations.AlterField(
model_name='channeldb',
name='db_attributes',
field=models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute'),
),
migrations.AlterField(
model_name='channeldb',
name='db_object_subscriptions',
field=models.ManyToManyField(blank=True, db_index=True, related_name='object_subscription_set', to='objects.ObjectDB', verbose_name=b'subscriptions'),
),
migrations.AlterField(
model_name='channeldb',
name='db_subscriptions',
field=models.ManyToManyField(blank=True, db_index=True, related_name='subscription_set', to=settings.AUTH_USER_MODEL, verbose_name=b'subscriptions'),
),
migrations.AlterField(
model_name='channeldb',
name='db_tags',
field=models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag'),
),
migrations.AlterField(
model_name='msg',
name='db_hide_from_channels',
field=models.ManyToManyField(blank=True, related_name='hide_from_channels_set', to='comms.ChannelDB'),
),
migrations.AlterField(
model_name='msg',
name='db_hide_from_objects',
field=models.ManyToManyField(blank=True, related_name='hide_from_objects_set', to='objects.ObjectDB'),
),
migrations.AlterField(
model_name='msg',
name='db_hide_from_players',
field=models.ManyToManyField(blank=True, related_name='hide_from_players_set', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='msg',
name='db_receivers_channels',
field=models.ManyToManyField(blank=True, help_text=b'channel recievers', related_name='channel_set', to='comms.ChannelDB'),
),
migrations.AlterField(
model_name='msg',
name='db_receivers_objects',
field=models.ManyToManyField(blank=True, help_text=b'object receivers', related_name='receiver_object_set', to='objects.ObjectDB'),
),
migrations.AlterField(
model_name='msg',
name='db_receivers_players',
field=models.ManyToManyField(blank=True, help_text=b'player receivers', related_name='receiver_player_set', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='msg',
name='db_sender_objects',
field=models.ManyToManyField(blank=True, db_index=True, related_name='sender_object_set', to='objects.ObjectDB', verbose_name=b'sender(object)'),
),
migrations.AlterField(
model_name='msg',
name='db_sender_players',
field=models.ManyToManyField(blank=True, db_index=True, related_name='sender_player_set', to=settings.AUTH_USER_MODEL, verbose_name=b'sender(player)'),
),
migrations.AlterField(
model_name='msg',
name='db_tags',
field=models.ManyToManyField(blank=True, help_text=b'tags on this message. Tags are simple string markers to identify, group and alias messages.', to='typeclasses.Tag'),
),
]

View file

@ -81,11 +81,11 @@ class Msg(SharedMemoryModel):
# 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_players = models.ManyToManyField("players.PlayerDB", related_name='sender_player_set',
null=True, blank=True, verbose_name='sender(player)', db_index=True)
blank=True, verbose_name='sender(player)', db_index=True)
db_sender_objects = models.ManyToManyField("objects.ObjectDB", related_name='sender_object_set',
null=True, blank=True, verbose_name='sender(object)', db_index=True)
blank=True, verbose_name='sender(object)', db_index=True)
db_sender_scripts = models.ManyToManyField("scripts.ScriptDB", related_name='sender_script_set',
null=True, blank=True, verbose_name='sender(script)', db_index=True)
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).")
@ -93,13 +93,13 @@ class Msg(SharedMemoryModel):
# comma-separated string of object dbrefs. Can be defined along
# with channels below.
db_receivers_players = models.ManyToManyField('players.PlayerDB', related_name='receiver_player_set',
null=True, blank=True, help_text="player receivers")
blank=True, help_text="player receivers")
db_receivers_objects = models.ManyToManyField('objects.ObjectDB', related_name='receiver_object_set',
null=True, blank=True, help_text="object receivers")
blank=True, help_text="object receivers")
db_receivers_scripts = models.ManyToManyField('scripts.ScriptDB', related_name='receiver_script_set',
null=True, blank=True, help_text="script_receivers")
blank=True, help_text="script_receivers")
db_receivers_channels = models.ManyToManyField("ChannelDB", related_name='channel_set',
null=True, blank=True, help_text="channel recievers")
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.
@ -113,11 +113,11 @@ class Msg(SharedMemoryModel):
help_text='access locks on this message.')
# these can be used to filter/hide a given message from supplied objects/players/channels
db_hide_from_players = models.ManyToManyField("players.PlayerDB", related_name='hide_from_players_set', null=True, blank=True)
db_hide_from_objects = models.ManyToManyField("objects.ObjectDB", related_name='hide_from_objects_set', null=True, blank=True)
db_hide_from_channels = models.ManyToManyField("ChannelDB", related_name='hide_from_channels_set', null=True, blank=True)
db_hide_from_players = models.ManyToManyField("players.PlayerDB", related_name='hide_from_players_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, null=True, 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
@ -564,6 +564,24 @@ class SubscriptionHandler(object):
self._recache()
return self._cache
def online(self):
"""
Get all online players from our cache
Returns:
subscribers (list): Subscribers who are online or
are puppeted by an online player.
"""
subs = []
for obj in self.all():
if hasattr(obj, 'player'):
if not obj.player:
continue
obj = obj.player
if not obj.is_connected:
continue
subs.append(obj)
return subs
def clear(self):
"""
Remove all subscribers from channel.
@ -588,10 +606,10 @@ class ChannelDB(TypedObject):
"""
db_subscriptions = models.ManyToManyField("players.PlayerDB",
related_name="subscription_set", null=True, blank=True, verbose_name='subscriptions', db_index=True)
related_name="subscription_set", blank=True, verbose_name='subscriptions', db_index=True)
db_object_subscriptions = models.ManyToManyField("objects.ObjectDB",
related_name="object_subscription_set", null=True, blank=True, verbose_name='subscriptions', db_index=True)
related_name="object_subscription_set", blank=True, verbose_name='subscriptions', db_index=True)
# Database manager
objects = managers.ChannelDBManager()

View file

@ -19,6 +19,8 @@ things you want from here into your game folder and change them there.
for any game. Allows safe trading of any godds (including coin)
* CharGen (Griatch 2011) - A simple Character creator for OOC mode.
Meant as a starting point for a more fleshed-out system.
* Clothing (BattleJenkins 2017) - A layered clothing system with
slots for different types of garments auto-showing in description.
* Custom gametime (Griatch, vlgeoff 2017) - Implements Evennia's
gametime module but for custom game world-specific calendars.
* Dice (Griatch 2012) - A fully featured dice rolling system.
@ -45,6 +47,8 @@ things you want from here into your game folder and change them there.
time to pass depending on if you are walking/running etc.
* Talking NPC (Griatch 2011) - A talking NPC object that offers a
menu-driven conversation tree.
* Turnbattle (BattleJenkins 2017) - A turn-based combat engine meant
as a start to build from. Has attack/disengage and turn timeouts.
* Wilderness (titeuf87 2017) - Make infinitely large wilderness areas
with dynamically created locations.

696
evennia/contrib/clothing.py Normal file
View file

@ -0,0 +1,696 @@
"""
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 do the same with the ClothedCharacterCmdSet in your game's
default_cmdsets.py:
from evennia.contrib.clothing import ClothedCharacterCmdSet
class CharacterCmdSet(default_cmds.CharacterCmdSet):
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'
"""
from evennia import DefaultObject
from evennia import DefaultCharacter
from evennia import default_cmds
from evennia.commands.default.muxcommand import MuxCommand
from evennia.utils import list_to_string
from evennia.utils import evtable
# 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
def order_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...
for current_type in reversed(CLOTHING_TYPE_ORDER):
# Check each item in the given clothes list.
for clothes in clothes_list:
# If the item has a clothing type...
if clothes.db.clothing_type:
item_type = clothes.db.clothing_type
# And the clothing type matches the current type...
if item_type == current_type:
# Move it to the front of the list!
ordered_clothes_list.remove(clothes)
ordered_clothes_list.insert(0, clothes)
return ordered_clothes_list
def get_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.
Kwargs:
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 = []
for thing in character.contents:
# If uncovered or not excluding covered items
if not thing.db.covered_by or exclude_covered is False:
# If 'worn' is True, add to the list
if thing.db.worn:
clothes_list.append(thing)
# Might as well put them in order here too.
ordered_clothes_list = order_clothes_list(clothes_list)
return ordered_clothes_list
def clothing_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 = {}
for garment in clothes_list:
if garment.db.clothing_type:
type = garment.db.clothing_type
if type not in types_count.keys():
types_count[type] = 1
else:
types_count[type] += 1
return types_count
def single_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
for garment in clothes_list:
if garment.db.clothing_type:
if garment.db.clothing_type == type:
type_count += 1
return type_count
class Clothing(DefaultObject):
def wear(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
Kwargs:
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 = []
if self.db.clothing_type and self.db.clothing_type in CLOTHING_TYPE_AUTOCOVER:
for garment in get_worn_clothes(wearer):
if garment.db.clothing_type and garment.db.clothing_type \
in CLOTHING_TYPE_AUTOCOVER[self.db.clothing_type]:
to_cover.append(garment)
garment.db.covered_by = self
# Return if quiet
if quiet:
return
# Echo a message to the room
message = "%s puts on %s" % (wearer, self.name)
if wearstyle is not True:
message = "%s wears %s %s" % (wearer, self.name, wearstyle)
if to_cover:
message = message + ", covering %s" % list_to_string(to_cover)
wearer.location.msg_contents(message + ".")
def remove(self, wearer, quiet=False):
"""
Removes worn clothes and optionally echoes to the room.
Args:
wearer (obj): character object wearing this clothing object
Kwargs:
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.
for thing in wearer.contents:
# If anything is covered by
if thing.db.covered_by == self:
thing.db.covered_by = False
uncovered_list.append(thing.name)
if len(uncovered_list) > 0:
remove_message = "%s removes %s, revealing %s." % (wearer, self.name, list_to_string(uncovered_list))
# Echo a message to the room
if not quiet:
wearer.location.msg_contents(remove_message)
def at_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
class ClothedCharacter(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.
"""
def return_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.
"""
if not looker:
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
for garment in clothes_list:
# If 'worn' is True, just append the name
if garment.db.worn is True:
worn_string_list.append(garment.name)
# Otherwise, append the name and the string value of 'worn'
elif garment.db.worn:
worn_string_list.append("%s %s" % (garment.name, garment.db.worn))
if desc:
string += "%s" % desc
# Append worn clothes.
if worn_string_list:
string += "|/|/%s is wearing %s." % (self, list_to_string(worn_string_list))
else:
string += "|/|/%s is not wearing anything." % self
return string
# COMMANDS START HERE
class CmdWear(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"
def func(self):
"""
This performs the actual command.
"""
if not self.args:
self.caller.msg("Usage: wear <obj> [wear style]")
return
clothing = self.caller.search(self.arglist[0], candidates=self.caller.contents)
wearstyle = True
if not clothing:
return
if not clothing.is_typeclass("evennia.contrib.clothing.Clothing"):
self.caller.msg("That's not clothes!")
return
# Enforce overall clothing limit.
if CLOTHING_OVERALL_LIMIT and len(get_worn_clothes(self.caller)) >= CLOTHING_OVERALL_LIMIT:
self.caller.msg("You can't wear any more clothes.")
return
# Apply individual clothing type limits.
if clothing.db.clothing_type and not clothing.db.worn:
type_count = single_type_count(get_worn_clothes(self.caller), clothing.db.clothing_type)
if clothing.db.clothing_type in CLOTHING_TYPE_LIMIT.keys():
if type_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
if clothing.db.worn and len(self.arglist) == 1:
self.caller.msg("You're already wearing %s!" % clothing.name)
return
if len(self.arglist) > 1: # If wearstyle arguments given
wearstyle_list = self.arglist # Split arguments into a list of words
del wearstyle_list[0] # Leave first argument (the clothing item) out of the wearstyle
wearstring = ' '.join(str(e) for e in wearstyle_list) # Join list of args back into one string
if WEARSTYLE_MAXLENGTH and len(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)
class CmdRemove(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"
def func(self):
"""
This performs the actual command.
"""
clothing = self.caller.search(self.args, candidates=self.caller.contents)
if not clothing:
return
if not clothing.db.worn:
self.caller.msg("You're not wearing that!")
return
if clothing.db.covered_by:
self.caller.msg("You have to take off %s first." % clothing.db.covered_by.name)
return
clothing.remove(self.caller)
class CmdCover(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"
def func(self):
"""
This performs the actual command.
"""
if len(self.arglist) < 2:
self.caller.msg("Usage: cover <worn clothing> [with] <clothing object>")
return
# Get rid of optional 'with' syntax
if self.arglist[1].lower() == "with" and len(self.arglist) > 2:
del self.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)
if not to_cover or not cover_with:
return
if not to_cover.is_typeclass("evennia.contrib.clothing.Clothing"):
self.caller.msg("%s isn't clothes!" % to_cover.name)
return
if not cover_with.is_typeclass("evennia.contrib.clothing.Clothing"):
self.caller.msg("%s isn't clothes!" % cover_with.name)
return
if cover_with.db.clothing_type:
if cover_with.db.clothing_type in CLOTHING_TYPE_CANT_COVER_WITH:
self.caller.msg("You can't cover anything with that!")
return
if not to_cover.db.worn:
self.caller.msg("You're not wearing %s!" % to_cover.name)
return
if to_cover == cover_with:
self.caller.msg("You can't cover an item with itself!")
return
if cover_with.db.covered_by:
self.caller.msg("%s is covered by something else!" % cover_with.name)
return
if to_cover.db.covered_by:
self.caller.msg("%s is already covered by %s." % (cover_with.name, to_cover.db.covered_by.name))
return
if not cover_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
class CmdUncover(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"
def func(self):
"""
This performs the actual command.
"""
if not self.args:
self.caller.msg("Usage: uncover <worn clothing object>")
return
to_uncover = self.caller.search(self.args, candidates=self.caller.contents)
if not to_uncover:
return
if not to_uncover.db.worn:
self.caller.msg("You're not wearing %s!" % to_uncover.name)
return
if not to_uncover.db.covered_by:
self.caller.msg("%s isn't covered by anything!" % to_uncover.name)
return
covered_by = to_uncover.db.covered_by
if covered_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
class CmdDrop(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|$"
def func(self):
"""Implement command"""
caller = self.caller
if not self.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)
if not obj:
return
# This part is new!
# You can't drop clothing items that are covered.
if obj.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.
if obj.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)
class CmdGive(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|$"
def func(self):
"""Implement give"""
caller = self.caller
if not self.args or not self.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)
if not (to_give and target):
return
if target == caller:
caller.msg("You keep %s to yourself." % to_give.key)
return
if not to_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.
if to_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.
if to_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)
class CmdInventory(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"$"
def func(self):
"""check inventory"""
if not self.caller.contents:
self.caller.msg("You are not carrying or wearing anything.")
return
items = self.caller.contents
carry_table = evtable.EvTable(border="header")
wear_table = evtable.EvTable(border="header")
for item in items:
if not item.db.worn:
carry_table.add_row("|C%s|n" % item.name, item.db.desc or "")
if carry_table.nrows == 0:
carry_table.add_row("|CNothing.|n", "")
string = "|wYou are carrying:\n%s" % carry_table
for item in items:
if item.db.worn:
wear_table.add_row("|C%s|n" % item.name, item.db.desc or "")
if wear_table.nrows == 0:
wear_table.add_row("|CNothing.|n", "")
string += "|/|wYou are wearing:\n%s" % wear_table
self.caller.msg(string)
class ClothedCharacterCmdSet(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"
def at_cmdset_creation(self):
"""
Populates the cmdset
"""
super(ClothedCharacterCmdSet, self).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())

View file

@ -0,0 +1,869 @@
# Evennia event system
Vincent Le Goff 2017
This contrib adds the system of events in Evennia, allowing immortals (or other trusted builders) to
dynamically add features to individual objects. Using events, every immortal or privileged users
could have a specific room, exit, character, object or something else behave differently from its
"cousins". For these familiar with the use of softcode in MU`*`, like SMAUG MudProgs, the ability to
add arbitrary behavior to individual objects is a step toward freedom. Keep in mind, however, the
warning below, and read it carefully before the rest of the documentation.
## A WARNING REGARDING SECURITY
Evennia's event system will run arbitrary Python code without much restriction. Such a system is as
powerful as potentially dangerous, and you will have to keep in mind these points before deciding to
install it:
1. Untrusted people can run Python code on your game server with this system. Be careful about who
can use this system (see the permissions below).
2. You can do all of this in Python outside the game. The event system is not to replace all your
game feature.
## Basic structure and vocabulary
- At the basis of the event system are **events**. An **event** defines the context in which we
would like to call some arbitrary code. For instance, one event is defined on exits and will fire
every time a character traverses through this exit. Events are described on a
[typeclass](https://github.com/evennia/evennia/wiki/Typeclasses) (like
[exits](https://github.com/evennia/evennia/wiki/Objects#exits) in our example). All objects
inheriting from this typeclass will have access to this event.
- **Callbacks** can be set on individual objects, on events defined in code. These **callbacks**
can contain arbitrary code and describe a specific behavior for an object. When the event fires,
all callbacks connected to this object's event are executed.
To see the system in context, when an object is picked up (using the default `get` command), a
specific event is fired:
1. The event "get" is set on objects (on the `Object` typeclass).
2. When using the "get" command to pick up an object, this object's `at_get` hook is called.
3. A modified hook of DefaultObject is set by the event system. This hook will execute (or call)
the "get" event on this object.
4. All callbacks tied to this object's "get" event will be executed in order. These callbacks act
as functions containing Python code that you can write in-game, using specific variables that
will be listed when you edit the callback itself.
5. In individual callbacks, you can add multiple lines of Python code that will be fired at this
point. In this example, the `character` variable will contain the character who has picked up
the object, while `obj` will contain the object that was picked up.
Following this example, if you create a callback "get" on the object "a sword", and put in it:
```python
character.msg("You have picked up {} and have completed this quest!".format(obj.get_display_name(character)))
```
When you pick up this object you should see something like:
You pick up a sword.
You have picked up a sword and have completed this quest!
## Installation
Being in a separate contrib, the event system isn't installed by default. You need to do it
manually, following these steps:
1. Launch the main script (important!):
```@py evennia.create_script("evennia.contrib.events.scripts.EventHandler")```
2. Set the permissions (optional):
- `EVENTS_WITH_VALIDATION`: a group that can edit callbacks, but will need approval (default to
`None`).
- `EVENTS_WITHOUT_VALIDATION`: a group with permission to edit callbacks without need of
validation (default to `"immortals"`).
- `EVENTS_VALIDATING`: a group that can validate callbacks (default to `"immortals"`).
- `EVENTS_CALENDAR`: type of the calendar to be used (either `None`, `"standard"` or `"custom"`,
default to `None`).
3. Add the `@call` command.
4. Inherit from the custom typeclasses of the event system.
- `evennia.contrib.events.typeclasses.EventCharacter`: to replace `DefaultCharacter`.
- `evennia.contrib.events.typeclasses.EventExit`: to replace `DefaultExit`.
- `evennia.contrib.events.typeclasses.EventObject`: to replace `DefaultObject`.
- `evennia.contrib.events.typeclasses.EventRoom`: to replace `DefaultRoom`.
The following sections describe in details each step of the installation.
> Note: If you were to start the game without having started the main script (such as when
resetting your database) you will most likely face a traceback when logging in, telling you
that a 'callback' property is not defined. After performing step `1` the error will go away.
### Starting the event script
To start the event script, you only need a single command, using `@py`.
@py evennia.create_script("evennia.contrib.events.scripts.EventHandler")
This command will create a global script (that is, a script independent from any object). This
script will hold basic configuration, individual callbacks and so on. You may access it directly,
but you will probably use the callback handler. Creating this script will also create a `callback`
handler on all objects (see below for details).
### Editing permissions
This contrib comes with its own set of permissions. They define who can edit callbacks without
validation, and who can edit callbacks but needs validation. Validation is a process in which an
administrator (or somebody trusted as such) will check the callbacks produced by others and will
accept or reject them. If accepted, the callbacks are connected, otherwise they are never run.
By default, callbacks can only be created by immortals: no one except the immortals can edit
callbacks, and immortals don't need validation. It can easily be changed, either through settings
or dynamically by changing permissions of users.
The events contrib adds three
[permissions](https://github.com/evennia/evennia/wiki/Locks#permissions) in the settings. You can
override them by changing the settings into your `server/conf/settings.py` file (see below for an
example). The settings defined in the events contrib are:
- `EVENTS_WITH_VALIDATION`: this defines a permission that can edit callbacks, but will need
approval. If you set this to `"wizards"`, for instance, users with the permission `"wizards"`
will be able to edit callbacks. These callbacks will not be connected, though, and will need to be
checked and approved by an administrator. This setting can contain `None`, meaning that no user is
allowed to edit callbacks with validation.
- `EVENTS_WITHOUT_VALIDATION`: this setting defines a permission allowing editing of callbacks
without needing validation. By default, this setting is set to `"immortals"`. It means that
immortals can edit callbacks, and they will be connected when they leave the editor, without needing
approval.
- `EVENTS_VALIDATING`: this last setting defines who can validate callbacks. By default, this is
set to `"immortals"`, meaning only immortals can see callbacks needing validation, accept or
reject them.
You can override all these settings in your `server/conf/settings.py` file. For instance:
```python
# ... other settings ...
# Event settings
EVENTS_WITH_VALIDATION = "wizards"
EVENTS_WITHOUT_VALIDATION = "immortals"
EVENTS_VALIDATING = "immortals"
```
In addition, there is another setting that must be set if you plan on using the time-related events
(events that are scheduled at specific, in-game times). You would need to specify the type of
calendar you are using. By default, time-related events are disabled. You can change the
`EVENTS_CALENDAR` to set it to:
- `"standard"`: the standard calendar, with standard days, months, years and so on.
- `"custom"`: a custom calendar that will use the
[custom_gametime](https://github.com/evennia/evennia/blob/master/evennia/contrib/custom_gametime.py)
contrib to schedule events.
This contrib defines two additional permissions that can be set on individual users:
- `events_without_validation`: this would give this user the rights to edit callbacks but not
require validation before they are connected.
- `events_validating`: this permission allows this user to run validation checks on callbacks
needing to be validated.
For instance, to give the right to edit callbacks without needing approval to the player 'kaldara',
you might do something like:
@perm *kaldara = events_without_validation
To remove this same permission, just use the `/del` switch:
@perm/del *kaldara = events_without_validation
The rights to use the `@call` command are directly related to these permissions: by default, only
users who have the `events_without_validation` permission or are in (or above) the group defined in
the `EVENTS_WITH_VALIDATION` setting will be able to call the command (with different switches).
### Adding the `@call` command
You also have to add the `@call` command to your Character CmdSet. This command allows your users
to add, edit and delete callbacks in-game. In your `commands/default_cmdsets, it might look like
this:
```python
from evennia import default_cmds
from evennia.contrib.events.commands import CmdCallback
class CharacterCmdSet(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 `PlayerCmdSet` when a Player puppets a Character.
"""
key = "DefaultCharacter"
def at_cmdset_creation(self):
"""
Populates the cmdset
"""
super(CharacterCmdSet, self).at_cmdset_creation()
self.add(CmdCallback())
```
### Changing parent classes of typeclasses
Finally, to use the event system, you need to have your typeclasses inherit from the modified event
classes. For instance, in your `typeclasses/characters.py` module, you should change inheritance
like this:
```python
from evennia.contrib.events.typeclasses import EventCharacter
class Character(EventCharacter):
# ...
```
You should do the same thing for your rooms, exits and objects. Note that the event system works by
overriding some hooks. Some of these features might not be accessible in your game if you don't
call the parent methods when overriding hooks.
## Using the `@call` command
The event system relies, to a great extent, on its `@call` command. Who can execute this command,
and who can do what with it, will depend on your set of permissions.
The `@call` command allows to add, edit and delete callbacks on specific objects' events. The event
system can be used on most Evennia objects, mostly typeclassed objects (excluding players). The
first argument of the `@call` command is the name of the object you want to edit. It can also be
used to know what events are available for this specific object.
### Examining callbacks and events
To see the events connected to an object, use the `@call` command and give the name or ID of the
object to examine. For instance, @call here` to examine the events on your current location. Or
`@call self` to see the events on yourself.
This command will display a table, containing:
- The name of each event in the first column.
- The number of callbacks of this name, and the number of total lines of these callbacks in the
second column.
- A short help to tell you when the event is triggered in the third column.
If you execute `@call #1` for instance, you might see a table like this:
```
+------------------+---------+-----------------------------------------------+
| Event name | Number | Description |
+~~~~~~~~~~~~~~~~~~+~~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| can_delete | 0 (0) | Can the character be deleted? |
| can_move | 0 (0) | Can the character move? |
| can_part | 0 (0) | Can the departing character leave this room? |
| delete | 0 (0) | Before deleting the character. |
| greet | 0 (0) | A new character arrives in the location of |
| | | this character. |
| move | 0 (0) | After the character has moved into its new |
| | | room. |
| puppeted | 0 (0) | When the character has been puppeted by a |
| | | player. |
| time | 0 (0) | A repeated event to be called regularly. |
| unpuppeted | 0 (0) | When the character is about to be un- |
| | | puppeted. |
+------------------+---------+-----------------------------------------------+
```
### Creating a new callback
The `/add` switch should be used to add a callback. It takes two arguments beyond the object's
name/DBREF:
1. After an = sign, the name of the event to be edited (if not supplied, will display the list of
possible events, like above).
2. The parameters (optional).
We'll see callbacks with parameters later. For the time being, let's try to prevent a character
from going through the "north" exit of this room:
```
@call north
+------------------+---------+-----------------------------------------------+
| Event name | Number | Description |
+~~~~~~~~~~~~~~~~~~+~~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| can_traverse | 0 (0) | Can the character traverse through this exit? |
| 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. |
| time | 0 (0) | A repeated event to be called regularly. |
| traverse | 0 (0) | After the character has traversed through |
| | | this exit. |
+------------------+---------+-----------------------------------------------+
```
If we want to prevent a character from traversing through this exit, the best event for us would be
"can_traverse".
> Why not "traverse"? If you read the description of both events, you will see "traverse" is called
**after** the character has traversed through this exit. It would be too late to prevent it. On
> the other hand, "can_traverse" is obviously checked before the character traverses.
When we edit the event, we have some more information:
@call/add north = can_traverse
Can the character traverse through this exit?
This event is called when a character is about to traverse this
exit. You can use the deny() eventfunc to deny the character from
exiting for this time.
Variables you can use in this event:
- character: the character that wants to traverse this exit.
- exit: the exit to be traversed.
- room: the room in which stands the character before moving.
The section dedicated to [eventfuncs](#the-eventfuncs) will elaborate on the `deny()` function and
other eventfuncs. Let us say, for the time being, that it can prevent an action (in this case, it
can prevent the character from traversing through this exit). In the editor that opened when you
used `@call/add`, you can type something like:
```python
if character.id == 1:
character.msg("You're the superuser, 'course I'll let you pass.")
else:
character.msg("Hold on, what do you think you're doing?")
deny()
```
You can now enter `:wq` to leave the editor by saving the callback.
If you enter `@call north`, you should see that "can_traverse" now has an active callback. You can
use `@call north = can_traverse` to see more details on the connected callbacks:
```
@call north = can_traverse
+--------------+--------------+----------------+--------------+--------------+
| Number | Author | Updated | Param | Valid |
+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+
| 1 | XXXXX | 5 seconds ago | | Yes |
+--------------+--------------+----------------+--------------+--------------+
```
The left column contains callback numbers. You can use them to have even more information on a
specific event. Here, for instance:
```
@call north = can_traverse 1
Callback can_traverse 1 of north:
Created by XXXXX on 2017-04-02 17:58:05.
Updated by XXXXX on 2017-04-02 18:02:50
This callback is connected and active.
Callback code:
if character.id == 1:
character.msg("You're the superuser, 'course I'll let you pass.")
else:
character.msg("Hold on, what do you think you're doing?")
deny()
```
Then try to walk through this exit. Do it with another character if possible, too, to see the
difference.
### Editing and removing a callback
You can use the `/edit` switch to the `@call` command to edit a callback. You should provide, after
the name of the object to edit and the equal sign:
1. The name of the event (as seen above).
2. A number, if several callbacks are connected at this location.
You can type `@call/edit <object> = <event name>` to see the callbacks that are linked at this
location. If there is only one callback, it will be opened in the editor; if more are defined, you
will be asked for a number to provide (for instance, `@call/edit north = can_traverse 2`).
The command `@call` also provides a `/del` switch to remove a callback. It takes the same arguments
as the `/edit` switch.
When removed, callbacks are logged, so an administrator can retrieve its content, assuming the
`/del` was an error.
### The code editor
When adding or editing a callback, the event editor should open in code mode. The additional
options supported by the editor in this mode are describe in [a dedicated section of the EvEditor's
documentation](https://github.com/evennia/evennia/wiki/EvEditor#the-eveditor-to-edit-code).
## Using events
The following sections describe how to use events for various tasks, from the most simple to the
most complex.
### The eventfuncs
In order to make development a little easier, the event system provides eventfuncs to be used in
callbacks themselves. You don't have to use them, they are just shortcuts. An eventfunc is just a
simple function that can be used inside of your callback code.
Function | Argument | Description | Example
-----------|--------------------------|-----------------------------------|--------
deny | `()` | Prevent an action from happening. | `deny()`
get | `(**kwargs)` | Get a single object. | `char = get(id=1)`
call_event | `(obj, name, seconds=0)` | Call another event. | `call_event(char, "chain_1", 20)`
#### deny
The `deny()` function allows to interrupt the callback and the action that called it. In the
`can_*` events, it can be used to prevent the action from happening. For instance, in `can_say` on
rooms, it can prevent the character from saying something in the room. One could have a `can_eat`
event set on food that would prevent this character from eating this food.
Behind the scenes, the `deny()` function raises an exception that is being intercepted by the
handler of events. The handler will then report that the action was cancelled.
#### get
The `get` eventfunc is a shortcut to get a single object with a specific identity. It's often used
to retrieve an object with a given ID. In the section dedicated to [chained
events](#chained-events), you will see a concrete example of this function in action.
#### call_event
Some callbacks will call other events. It is particularly useful for [chained
events](#chained-events) that are described in a dedicated section. This eventfunc is used to call
another event, immediately or in a defined time.
You need to specify as first parameter the object containing the event. The second parameter is the
name of the event to call. The third parameter is the number of seconds before calling this event.
By default, this parameter is set to 0 (the event is called immediately).
### Variables in callbacks
In the Python code you will enter in individual callbacks, you will have access to variables in your
locals. These variables will depend on the event, and will be clearly listed when you add or edit a
callback. As you've seen in the previous example, when we manipulate characters or character
actions, we often have a `character` variable that holds the character doing the action.
In most cases, when an event is fired, all callbacks from this event are called. Variables are
created for each event. Sometimes, however, the callback will execute and then ask for a variable
in your locals: in other words, some callbacks can alter the actions being performed by changing
values of variables. This is always clearly specified in the help of the event.
One example that will illustrate this system is the "msg_leave" event that can be set on exits.
This event can alter the message that will be sent to other characters when someone leaves through
this exit.
@call/add down = msg_leave
Which should display:
```
Customize the message when a character leaves through this exit.
This event is called when a character leaves through this exit.
To customize the message that will be sent to the room where the
character came from, change the value of the variable "message"
to give it your custom message. The character itself will not be
notified. You can use mapping between braces, like this:
message = "{character} falls into a hole!"
In your mapping, you can use {character} (the character who is
about to leave), {exit} (the exit), {origin} (the room in which
the character is), and {destination} (the room in which the character
is heading for). If you need to customize the message with other
information, you can also set "message" to None and send something
else instead.
Variables you can use in this event:
character: the character who is leaving through this exit.
exit: the exit being traversed.
origin: the location of the character.
destination: the destination of the character.
message: the message to be displayed in the location.
mapping: a dictionary containing additional mapping.
```
If you write something like this in your event:
```python
message = "{character} falls into a hole in the ground!"
```
And if the character Wilfred takes this exit, others in the room will see:
Wildred falls into a hole in the ground!
In this case, the event system placed the variable "message" in the callback locals, but will read
from it when the event has been executed.
### Callbacks with parameters
Some callbacks are called without parameter. It has been the case for all examples we have seen
before. In some cases, you can create callbacks that are triggered under only some conditions. A
typical example is the room's "say" event. This event is triggered when somebody says something in
the room. Individual callbacks set on this event can be configured to fire only when some words are
used in the sentence.
For instance, let's say we want to create a cool voice-operated elevator. You enter into the
elevator and say the floor number... and the elevator moves in the right direction. In this case,
we could create an callback with the parameter "one":
@call/add here = say one
This callback will only fire when the user says a sentence that contains "one".
But what if we want to have a callback that would fire if the user says 1 or one? We can provide
several parameters, separated by a comma.
@call/add here = say 1, one
Or, still more keywords:
@call/add here = say 1, one, ground
This time, the user could say something like "take me to the ground floor" ("ground" is one of our
keywords defined in the above callback).
Not all events can take parameters, and these who do have different ways of handling them. There
isn't a single meaning to parameters that could apply to all events. Refer to the event
documentation for details.
> If you get confused between callback variables and parameters, think of parameters as checks
> performed before the callback is run. Event with parameters will only fire some specific
> callbacks, not all of them.
### Time-related events
Events are usually linked to commands, as we saw before. However, this is not always the case.
Events can be triggered by other actions and, as we'll see later, could even be called from inside
other events!
There is a specific event, on all objects, that can trigger at a specific time. It's an event with
a mandatory parameter, which is the time you expect this event to fire.
For instance, let's add an event on this room that should trigger every day, at precisely 12:00 PM
(the time is given as game time, not real time):
@call here = time 12:00
```python
# This will be called every MUD day at 12:00 PM
room.msg_contents("It's noon, time to have lunch!")
```
Now, at noon every MUD day, this event will fire and this callback will be executed. You can use
this event on every kind of typeclassed object, to have a specific action done every MUD day at the
same time.
Time-related events can be much more complex than this. They can trigger every in-game hour or more
often (it might not be a good idea to have events trigger that often on a lot of objects). You can
have events that run every in-game week or month or year. It will greatly vary depending on the
type of calendar used in your game. The number of time units is described in the game
configuration.
With a standard calendar, for instance, you have the following units: minutes, hours, days, months
and years. You will specify them as numbers separated by either a colon (:), a space ( ), or a dash
(-). Pick whatever feels more appropriate (usually, we separate hours and minutes with a colon, the
other units with a dash).
Some examples of syntax:
- `18:30`: every day at 6:30 PM.
- `01 12:00`: every month, the first day, at 12 PM.
- `06-15 09:58`: every year, on the 15th of June (month comes before day), at 9:58 AM.
- `2025-01-01 00:00`: January 1st, 2025 at midnight (obviously, this will trigger only once).
Notice that we specify units in the reverse order (year, month, day, hour and minute) and separate
them with logical separators. The smallest unit that is not defined is going to set how often the
event should fire. That's why, if you use `12:00`, the smallest unit that is not defined is "day":
the event will fire every day at the specified time.
> You can use chained events (see below) in conjunction with time-related events to create more
random or frequent actions in events.
### Chained events
Callbacks can call other events, either now or a bit later. It is potentially very powerful.
To use chained events, just use the `call_event` eventfunc. It takes 2-3 arguments:
- The object containing the event.
- The name of the event to call.
- Optionally, the number of seconds to wait before calling this event.
All objects have events that are not triggered by commands or game-related operations. They are
called "chain_X", like "chain_1", "chain_2", "chain_3" and so on. You can give them more specific
names, as long as it begins by "chain_", like "chain_flood_room".
Rather than a long explanation, let's look at an example: a subway that will go from one place to
the next at regular times. Connecting exits (opening its doors), waiting a bit, closing them,
rolling around and stopping at a different station. That's quite a complex set of callbacks, as it
is, but let's only look at the part that opens and closes the doors:
@call/add here = time 10:00
```python
# At 10:00 AM, the subway arrives in the room of ID 22.
# Notice that exit #23 and #24 are respectively the exit leading
# on the platform and back in the subway.
station = get(id=22)
to_exit = get(id=23)
back_exit = get(id=24)
# Open the door
to_exit.name = "platform"
to_exit.aliases = ["p"]
to_exit.location = room
to_exit.destination = station
back_exit.name = "subway"
back_exit.location = station
back_exit.destination = room
# Display some messages
room.msg_contents("The doors open and wind gushes in the subway")
station.msg_contents("The doors of the subway open with a dull clank.")
# Set the doors to close in 20 seconds
call_event(room, "chain_1", 20)
```
This callback will:
1. Be called at 10:00 AM (specify 22:00 to set it to 10:00 PM).
2. Set an exit between the subway and the station. Notice that the exits already exist (you will
not have to create them), but they don't need to have specific location and destination.
3. Display a message both in the subway and on the platform.
4. Call the event "chain_1" to execute in 20 seconds.
And now, what should we have in "chain_1"?
@call/add here = chain_1
```python
# Close the doors
to_exit.location = None
to_exit.destination = None
back_exit.location = None
back_exit.destination = None
room.msg_content("After a short warning signal, the doors close and the subway begins moving.")
station.msg_content("After a short warning signal, the doors close and the subway begins moving.")
```
Behind the scenes, the `call_event` function freezes all variables ("room", "station", "to_exit",
"back_exit" in our example), so you don't need to define them again.
A word of caution on callbacks that call chained events: it isn't impossible for a callback to call
itself at some recursion level. If `chain_1` calls `chain_2` that calls `chain_3` that calls
`chain_`, particularly if there's no pause between them, you might run into an infinite loop.
Be also careful when it comes to handling characters or objects that may very well move during your
pause between event calls. When you use `call_event()`, the MUD doesn't pause and commands can be
entered by players, fortunately. It also means that, a character could start an event that pauses
for awhile, but be gone when the chained event is called. You need to check that, even lock the
character into place while you are pausing (some actions should require locking) or at least,
checking that the character is still in the room, for it might create illogical situations if you
don't.
> Chained events are a special case: contrary to standard events, they are created in-game, not
through code. They usually contain only one callback, although nothing prevents you from creating
several chained events in the same object.
## Using events in code
This section describes callbacks and events from code, how to create new events, how to call them in
a command, and how to handle specific cases like parameters.
Along this section, we will see how to implement the following example: we would like to create a
"push" command that could be used to push objects. Objects could react to this command and have
specific events fired.
### Adding new events
Adding new events should be done in your typeclasses. Events are contained in the `_events` class
variable, a dictionary of event names as keys, and tuples to describe these events as values. You
also need to register this class, to tell the event system that it contains events to be added to
this typeclass.
Here, we want to add a "push" event on objects. In your `typeclasses/objects.py` file, you should
write something like:
```python
from evennia.contrib.events.utils import register_events
from evennia.contrib.events.typeclasses import EventObject
EVENT_PUSH = """
A character push the object.
This event is called when a character uses the "push" command on
an object in the same room.
Variables you can use in this event:
character: the character that pushes this object.
obj: the object connected to this event.
"""
@register_events
class Object(EventObject):
"""
Class representing objects.
"""
_events = {
"push": (["character", "obj"], EVENT_PUSH),
}
```
- Line 1-2: we import several things we will need from the event system. Note that we use
`EventObject` as a parent instead of `DefaultObject`, as explained in the installation.
- Line 4-12: we usually define the help of the event in a separate variable, this is more readable,
though there's no rule against doing it another way. Usually, the help should contain a short
explanation on a single line, a longer explanation on several lines, and then the list of variables
with explanations.
- Line 14: we call a decorator on the class to indicate it contains events. If you're not familiar
with decorators, you don't really have to worry about it, just remember to put this line just
above the class definition if your class contains events.
- Line 15: we create the class inheriting from `EventObject`.
- Line 20-22: we define the events of our objects in an `_events` class variable. It is a
dictionary. Keys are event names. Values are a tuple containing:
- The list of variable names (list of str). This will determine what variables are needed when
the event triggers. These variables will be used in callbacks (as we'll see below).
- The event help (a str, the one we have defined above).
If you add this code and reload your game, create an object and examine its events with `@call`, you
should see the "push" event with its help. Of course, right now, the event exists, but it's not
fired.
### Calling an event in code
The event system is accessible through a handler on all objects. This handler is named `callbacks`
and can be accessed from any typeclassed object (your character, a room, an exit...). This handler
offers several methods to examine and call an event or callback on this object.
To call an event, use the `callbacks.call` method in an object. It takes as argument:
- The name of the event to call.
- All variables that will be accessible in the event as positional arguments. They should be
specified in the order chosen when [creating new events](#adding-new-events).
Following the same example, so far, we have created an event on all objects, called "push". This
event is never fired for the time being. We could add a "push" command, taking as argument the name
of an object. If this object is valid, it will call its "push" event.
```python
from commands.command import Command
class CmdPush(Command):
"""
Push something.
Usage:
push <something>
Push something where you are, like an elevator button.
"""
key = "push"
def func(self):
"""Called when pushing something."""
if not self.args.strip():
self.msg("Usage: push <something>")
return
# Search for this object
obj = self.caller.search(self.args)
if not obj:
return
self.msg("You push {}.".format(obj.get_display_name(self.caller)))
# Call the "push" event of this object
obj.callbacks.call("push", self.caller, obj)
```
Here we use `callbacks.call` with the following arguments:
- `"push"`: the name of the event to be called.
- `self.caller`: the one who pushed the button (this is our first variable, `character`).
- `obj`: the object being pushed (our second variable, `obj`).
In the "push" callbacks of our objects, we then can use the "character" variable (containing the one
who pushed the object), and the "obj" variable (containing the object that was pushed).
### See it all work
To see the effect of the two modifications above (the added event and the "push" command), let us
create a simple object:
@create/drop rock
@desc rock = It's a single rock, apparently pretty heavy. Perhaps you can try to push it though.
@call/add rock = push
In the callback you could write:
```python
from random import randint
number = randint(1, 6)
character.msg("You push a rock... is... it... going... to... move?")
if number == 6:
character.msg("The rock topples over to reveal a beautiful ant-hill!")
```
You can now try to "push rock". You'll try to push the rock, and once out of six times, you will
see a message about a "beautiful ant-hill".
### Adding new eventfuncs
Eventfuncs, like `deny(), are defined in `contrib/events/eventfuncs.py`. You can add your own
eventfuncs by creating a file named `eventfuncs.py` in your `world` directory. The functions
defined in this file will be added as helpers.
You can also decide to create your eventfuncs in another location, or even in several locations. To
do so, edit the `EVENTFUNCS_LOCATION` setting in your `server/conf/settings.py` file, specifying
either a python path or a list of Python paths in which your helper functions are defined. For
instance:
```python
EVENTFUNCS_LOCATIONS = [
"world.events.functions",
]
```
### Creating events with parameters
If you want to create events with parameters (if you create a "whisper" or "ask" command, for
instance, and need to have some characters automatically react to words), you can set an additional
argument in the tuple of events in your typeclass' ```_events``` class variable. This third argument
must contain a callback that will be called to filter through the list of callbacks when the event
fires. Two types of parameters are commonly used (but you can define more parameter types, although
this is out of the scope of this documentation).
- Keyword parameters: callbacks of this event will be filtered based on specific keywords. This is
useful if you want the user to specify a word and compare this word to a list.
- Phrase parameters: callbacks will be filtered using an entire phrase and checking all its words.
The "say" command uses phrase parameters (you can set a "say" callback to fires if a phrase
contains one specific word).
In both cases, you need to import a function from `evennia.contrib.events.utils` and use it as third
parameter in your event definition.
- `keyword_event` should be used for keyword parameters.
- `phrase_event` should be used for phrase parameters.
For example, here is the definition of the "say" event:
```python
from evennia.contrib.events.utils import register_events, phrase_event
# ...
@register_events
class SomeTypeclass:
_events = {
"say": (["speaker", "character", "message"], CHARACTER_SAY, phrase_event),
}
```
When you call an event using the `obj.callbacks.call` method, you should also provide the parameter,
using the `parameters` keyword:
```python
obj.callbacks.call(..., parameters="<put parameters here>")
```
It is necessary to specifically call the event with parameters, otherwise the system will not be
able to know how to filter down the list of callbacks.
## Disabling all events at once
When callbacks are running in an infinite loop, for instance, or sending unwanted information to
players or other sources, you, as the game administrator, have the power to restart without events.
The best way to do this is to use a custom setting, in your setting file
(`server/conf/settings.py`):
```python
# Disable all events
EVENTS_DISABLED = True
```
The event system will still be accessible (you will have access to the `@call` command, to debug),
but no event will be called automatically.

View file

View file

@ -0,0 +1,204 @@
"""
Module containing the CallbackHandler for individual objects.
"""
from collections import namedtuple
class CallbackHandler(object):
"""
The event handler for a specific object.
The script that contains all events 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
def __init__(self, obj):
self.obj = obj
def all(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
if handler:
dicts = handler.get_callbacks(self.obj)
for callback_name, in_list in dicts.items():
new_list = []
for callback in in_list:
callback = self.format_callback(callback)
new_list.append(callback)
if new_list:
callbacks[callback_name] = new_list
return callbacks
def get(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.
"""
return self.all().get(callback_name, [])
def get_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
if handler:
return handler.get_variable(variable_name)
return None
def add(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 Player, 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
if handler:
return self.format_callback(handler.add_callback(self.obj, callback_name, code,
author=author, valid=valid, parameters=parameters))
def edit(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 Player, 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
if handler:
return self.format_callback(handler.edit_callback(self.obj, callback_name,
number, code, author=author, valid=valid))
def remove(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
if handler:
handler.del_callback(self.obj, callback_name, number)
def call(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.
Kwargs:
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
if handler:
return handler.call(self.obj, callback_name, *args, **kwargs)
return None
@staticmethod
def format_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" not in callback:
callback["obj"] = None
if "name" not in callback:
callback["name"] = "unknown"
if "number" not in callback:
callback["number"] = -1
if "code" not in callback:
callback["code"] = ""
if "author" not in callback:
callback["author"] = None
if "valid" not in callback:
callback["valid"] = False
if "parameters" not in callback:
callback["parameters"] = ""
if "created_on" not in callback:
callback["created_on"] = None
if "updated_by" not in callback:
callback["updated_by"] = None
if "updated_on" not in callback:
callback["updated_on"] = None
return Callback(**callback)
Callback = namedtuple("Callback", ("obj", "name", "number", "code", "author",
"valid", "parameters", "created_on", "updated_by", "updated_on"))

View file

@ -0,0 +1,562 @@
"""
Module containing the commands of the callback system.
"""
from datetime import datetime
from django.conf import settings
from evennia import Command
from evennia.utils.ansi import raw
from evennia.utils.eveditor import EvEditor
from evennia.utils.evtable import EvTable
from evennia.utils.utils import class_from_module, time_format
from evennia.contrib.events.utils import get_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",
"immortals")
VALIDATING = getattr(settings, "callbackS_VALIDATING", "immortals")
# 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.
"""
class CmdCallback(COMMAND_DEFAULT_CLASS):
"""
Command to edit callbacks.
"""
key = "@call"
aliases = ["@callback", "@callbacks", "@calls"]
locks = "cmd:perm({})".format(VALIDATING)
if WITH_VALIDATION:
locks += " or perm({})".format(WITH_VALIDATION)
help_category = "Building"
def get_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 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.
"""
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)
if validator:
text += "\n " + "\n ".join(VALIDATOR_USAGES)
# Switches
text += "\n\nSwitches:\n "
text += "\n ".join(BASIC_SWITCHES)
if validator:
text += "\n " + "\n ".join(VALIDATOR_SWITCHES)
# Text
text += "\n" + BASIC_TEXT
if validator:
text += "\n" + VALIDATOR_TEXT
return text
def func(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.rhs or ""
self.callback_name, sep, self.parameters = rhs.partition(" ")
self.callback_name = self.callback_name.lower()
self.is_validator = validator
self.autovalid = autovalid
if self.handler is None:
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
if self.lhs:
self.obj = caller.search(self.lhs)
if not self.obj:
return
# Switches are mutually exclusive
switch = self.switches and self.switches[0] or ""
if switch in ("", "add", "edit", "del") and self.obj is None:
caller.msg("Specify an object's name or #ID.")
return
if switch == "":
self.list_callbacks()
elif switch == "add":
self.add_callback()
elif switch == "edit":
self.edit_callback()
elif switch == "del":
self.del_callback()
elif switch == "accept" and validator:
self.accept_callback()
elif switch in ["tasks", "task"]:
self.list_tasks()
else:
caller.msg("Mutually exclusive or invalid switches were " \
"used, cannot proceed.")
def list_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)
if callback_name:
# Check that the callback name can be found in this object
created = callbacks.get(callback_name)
if created is None:
self.msg("No callback {} has been set on {}.".format(callback_name,
obj))
return
if parameters:
# Check that the parameter points to an existing callback
try:
number = int(parameters) - 1
assert number >= 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.key if author else "|gUnknown|n"
updated_by = callback.get("updated_by")
updated_by = updated_by.key if updated_by else "|gUnknown|n"
created_on = callback.get("created_on")
created_on = created_on.strftime("%Y-%m-%d %H:%M:%S") if created_on else "|gUnknown|n"
updated_on = callback.get("updated_on")
updated_on = updated_on.strftime("%Y-%m-%d %H:%M:%S") if updated_on else "|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)
if self.is_validator:
if callback.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"]
if self.is_validator:
cols.append("Valid")
table = EvTable(*cols, width=78)
table.reformat_column(0, align="r")
now = datetime.now()
for i, callback in enumerate(created):
author = callback.get("author")
author = author.key if author else "|gUnknown|n"
updated_on = callback.get("updated_on")
if updated_on is None:
updated_on = callback.get("created_on")
if updated_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]
if self.is_validator:
row.append("Yes" if callback.get("valid") else "No")
table.add_row(*row)
self.msg(unicode(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)
for name in sorted(names):
number = len(callbacks.get(name, []))
lines = sum(len(e["code"].splitlines()) for e in callbacks.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(unicode(table))
def add_callback(self):
"""Add a callback."""
obj = self.obj
callback_name = self.callback_name
types = self.handler.get_events(obj)
# Check that the callback exists
if not callback_name.startswith("chain_") and not callback_name in types:
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)
def edit_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
if not callback_name:
self.list_callbacks()
return
# Check that the callback exists
if not callback_name in callbacks:
self.msg("The callback name {} can't be found in {}.".format(
callback_name, obj))
return
# If there's only one callback, just edit it
if len(callbacks[callback_name]) == 1:
number = 0
callback = callbacks[callback_name][0]
else:
if not parameters:
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
assert number >= 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
if not self.autovalid and callback["author"] is not self.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) in self.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)
callback["obj"] = obj
callback["name"] = callback_name
callback["number"] = number
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)
def del_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
if not callback_name:
self.list_callbacks()
return
# Check that the callback exists
if not callback_name in callbacks:
self.msg("The callback name {} can't be found in {}.".format(
callback_name, obj))
return
# If there's only one callback, just delete it
if len(callbacks[callback_name]) == 1:
number = 0
callback = callbacks[callback_name][0]
else:
if not parameters:
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
assert number >= 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
if not self.autovalid and callback["author"] is not self.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) in self.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))
def accept_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
if obj is None:
table = EvTable("ID", "Type", "Object", "Name", "Updated by",
"On", width=78)
table.reformat_column(0, align="r")
now = datetime.now()
for obj, name, number in self.handler.db.to_valid:
callbacks = self.handler.get_callbacks(obj).get(name)
if callbacks is None:
continue
try:
callback = callbacks[number]
except IndexError:
continue
type_name = obj.typeclass_path.split(".")[-1]
by = callback.get("updated_by")
by = by.key if by else "|gUnknown|n"
updated_on = callback.get("updated_on")
if updated_on is None:
updated_on = callback.get("created_on")
if updated_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(unicode(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
if not callback_name:
self.list_callbacks()
return
# Check that the callback exists
if not callback_name in callbacks:
self.msg("The callback name {} can't be found in {}.".format(
callback_name, obj))
return
if not parameters:
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
assert number >= 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
if callback["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))
def list_tasks(self):
"""List the active tasks."""
obj = self.obj
callback_name = self.callback_name
handler = self.handler
tasks = [(k, v[0], v[1], v[2]) for k, v in handler.db.tasks.items()]
if obj:
tasks = [task for task in tasks if task[2] is obj]
if callback_name:
tasks = [task for task in tasks if task[3] == callback_name]
tasks.sort()
table = EvTable("ID", "Object", "Callback", "In", width=78)
table.reformat_column(0, align="r")
now = datetime.now()
for task_id, future, obj, callback_name in tasks:
key = obj.get_display_name(self.caller)
delta = time_format((future - now).total_seconds(), 1)
table.add_row(task_id, key, callback_name, delta)
self.msg(unicode(table))
# Private functions to handle editing
def _ev_load(caller):
return caller.db._callback and caller.db._callback.get("code", "") or ""
def _ev_save(caller, buf):
"""Save and add the callback."""
lock = "perm({}) or perm(events_without_validation)".format(
WITHOUT_VALIDATION)
autovalid = caller.locks.check_lockstring(caller, lock)
callback = caller.db._callback
handler = get_event_handler()
if not handler or not callback or not all(key in callback for key in \
("obj", "name", "number", "valid")):
caller.msg("Couldn't save this callback.")
return False
if (callback["obj"], callback["name"], callback["number"]) in handler.db.locked:
handler.db.locked.remove((callback["obj"], callback["name"],
callback["number"]))
handler.edit_callback(callback["obj"], callback["name"], callback["number"], buf,
caller, valid=autovalid)
return True
def _ev_quit(caller):
callback = caller.db._callback
handler = get_event_handler()
if not handler or not callback or not all(key in callback for key in \
("obj", "name", "number", "valid")):
caller.msg("Couldn't save this callback.")
return False
if (callback["obj"], callback["name"], callback["number"]) in handler.db.locked:
handler.db.locked.remove((callback["obj"], callback["name"],
callback["number"]))
del caller.db._callback
caller.msg("Exited the code editor.")

View file

@ -0,0 +1,88 @@
"""
Module defining basic eventfuncs for the event system.
Eventfuncs are just Python functions that can be used inside of calllbacks.
"""
from evennia import ObjectDB, ScriptDB
from evennia.contrib.events.utils import InterruptEvent
def deny():
"""
Deny, that is stop, the event here.
Notes:
This function will raise an exception to terminate the event
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.
"""
raise InterruptEvent
def get(**kwargs):
"""
Return an object with the given search option or None if None is found.
Kwargs:
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)
except ObjectDB.DoesNotExist:
object = None
return object
def call_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
if script:
# If seconds is 0, call the event immediately
if seconds == 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)

View file

@ -0,0 +1,655 @@
"""
Scripts for the event system.
"""
from datetime import datetime, timedelta
from Queue import Queue
import re
import sys
import traceback
from django.conf import settings
from evennia import DefaultObject, DefaultScript, ChannelDB, ScriptDB
from evennia import logger
from evennia.utils.ansi import raw
from evennia.utils.create import create_channel
from evennia.utils.dbserialize import dbserialize
from evennia.utils.utils import all_from_module, delay, pypath_to_realpath
from evennia.contrib.events.callbackhandler import CallbackHandler
from evennia.contrib.events.utils import get_next_wait, EVENTS, InterruptEvent
# Constants
RE_LINE_ERROR = re.compile(r'^ File "\<string\>", line (\d+)')
class EventHandler(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.
"""
def at_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 = {}
def at_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 = {}
for typeclass, name, variables, help_text, custom_call, custom_add in EVENTS:
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.events.eventfuncs"]
addresses.extend(getattr(settings, "EVENTFUNCS_LOCATIONS", ["world.eventfuncs"]))
for address in addresses:
if pypath_to_realpath(address):
self.ndb.fresh_locals.update(all_from_module(address))
# Restart the delayed tasks
now = datetime.now()
for task_id, definition in tuple(self.db.tasks.items()):
future, obj, event_name, locals = definition
seconds = (future - now).total_seconds()
if seconds < 0:
seconds = 0
delay(seconds, complete_task, task_id)
# Place the script in the CallbackHandler
from evennia.contrib.events import typeclasses
CallbackHandler.script = self
DefaultObject.callbacks = typeclasses.EventObject.callbacks
# Create the channel if non-existent
try:
self.ndb.channel = ChannelDB.objects.get(db_key="everror")
except ChannelDB.DoesNotExist:
self.ndb.channel = create_channel("everror", desc="Event errors",
locks="control:false();listen:perm(Builders);send:false()")
def get_events(self, obj):
"""
Return a dictionary of events on this object.
Args:
obj (Object): the connected object.
Returns:
A dictionary of the object's events.
Note:
Events would define what the object can have as
callbacks. Note, however, that chained callbacks will not
appear in events and are handled separately.
"""
events = {}
all_events = self.ndb.events
classes = Queue()
classes.put(type(obj))
invalid = []
while not classes.empty():
typeclass = classes.get()
typeclass_name = typeclass.__module__ + "." + typeclass.__name__
for key, etype in all_events.get(typeclass_name, {}).items():
if key in invalid:
continue
if etype[0] is None: # Invalidate
invalid.append(key)
continue
if key not in events:
events[key] = etype
# Look for the parent classes
for parent in typeclass.__bases__:
classes.put(parent)
return events
def get_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.
"""
return self.ndb.current_locals.get(variable_name)
def get_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 = {}
for callback_name, callback_list in obj_callbacks.items():
new_list = []
for i, callback in enumerate(callback_list):
callback = dict(callback)
callback["obj"] = obj
callback["name"] = callback_name
callback["number"] = i
new_list.append(callback)
if new_list:
callbacks[callback_name] = new_list
return callbacks
def add_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 Player, 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, {})
if not obj_callbacks:
self.db.callbacks[obj] = {}
obj_callbacks = self.db.callbacks[obj]
callbacks = obj_callbacks.get(callback_name, [])
if not callbacks:
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'
if not valid:
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]
if custom_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
return definition
def edit_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 Player, 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, {})
if not obj_callbacks:
self.db.callbacks[obj] = {}
obj_callbacks = self.db.callbacks[obj]
callbacks = obj_callbacks.get(callback_name, [])
if not callbacks:
obj_callbacks[callback_name] = []
callbacks = obj_callbacks[callback_name]
# If locked, don't edit it
if (obj, callback_name, number) in self.db.locked:
raise RuntimeError("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'
if not valid and (obj, callback_name, number) not in self.db.to_valid:
self.db.to_valid.append((obj, callback_name, number))
elif valid and (obj, callback_name, number) in self.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
return definition
def del_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) in self.db.locked:
raise RuntimeError("this callback is locked.")
# Delete the callback itself
try:
code = callbacks[number]["code"]
except IndexError:
return
else:
logger.log_info("Deleting callback {} {} of {}:\n{}".format(
callback_name, number, obj, code))
del callbacks[number]
# Change IDs of callbacks to be validated
i = 0
while i < len(self.db.to_valid):
t_obj, t_callback_name, t_number = self.db.to_valid[i]
if obj is t_obj and callback_name == t_callback_name:
if t_number == number:
# Strictly equal, delete the callback
del self.db.to_valid[i]
i -= 1
elif t_number > number:
# Change the ID for this callback
self.db.to_valid.insert(i, (t_obj, t_callback_name,
t_number - 1))
del self.db.to_valid[i + 1]
i += 1
# Update locked callback
for i, line in enumerate(self.db.locked):
t_obj, t_callback_name, t_number = line
if obj is t_obj and callback_name == t_callback_name:
if number < t_number:
self.db.locked[i] = (t_obj, t_callback_name, t_number - 1)
# Delete time-related callbacks associated with this object
for script in list(obj.scripts.all()):
if isinstance(script, TimecallbackScript):
if script.obj is obj and script.db.callback_name == callback_name:
if script.db.number == number:
script.stop()
elif script.db.number > number:
script.db.number -= 1
def accept_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) in self.db.to_valid:
self.db.to_valid.remove((obj, callback_name, number))
def call(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.
Kwargs:
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")
if any(k for k in kwargs if k not in allowed):
raise TypeError("Unknown keyword arguments were specified " \
"to call callbacks: {}".format(kwargs))
event = self.get_events(obj).get(callback_name)
if locals is None and not event:
logger.log_err("The callback {} for the object {} (typeclass " \
"{}) can't be found".format(callback_name, obj, type(obj)))
return False
# Prepare the locals if necessary
if locals is None:
locals = self.ndb.fresh_locals.copy()
for i, variable in enumerate(event[0]):
try:
locals[variable] = args[i]
except IndexError:
logger.log_trace("callback {} of {} ({}): need variable " \
"{} in position {}".format(callback_name, obj,
type(obj), variable, i))
return False
else:
locals = {key: value for key, value in locals.items()}
callbacks = self.get_callbacks(obj).get(callback_name, [])
if event:
custom_call = event[2]
if custom_call:
callbacks = custom_call(callbacks, parameters)
# Now execute all the valid callbacks linked at this address
self.ndb.current_locals = locals
for i, callback in enumerate(callbacks):
if not callback["valid"]:
continue
if number is not None and callback["number"] != number:
continue
try:
exec(callback["code"], locals, locals)
except InterruptEvent:
return False
except Exception:
etype, evalue, tb = sys.exc_info()
trace = traceback.format_exception(etype, evalue, tb)
self.handle_error(callback, trace)
return True
def handle_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"
for error in trace:
if error.startswith(' File "<string>", line '):
res = RE_LINE_ERROR.search(error)
if res:
lineno = int(res.group(1))
# Try to extract the line
try:
line = raw(callback["code"].splitlines()[lineno - 1])
except IndexError:
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")
if updater is None:
updater = callback["created_by"]
if updater and updater.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)
def add_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.
"""
if typeclass not in self.ndb.events:
self.ndb.events[typeclass] = {}
events = self.ndb.events[typeclass]
if name not in events:
events[name] = (variables, help_text, custom_call, custom_add)
def set_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
while task_id in used_ids:
task_id += 1
# Collect and freeze current locals
locals = {}
for key, value in self.ndb.current_locals.items():
try:
dbserialize(value)
except TypeError:
continue
else:
locals[key] = value
self.db.tasks[task_id] = (now + delta, obj, callback_name, locals)
delay(seconds, complete_task, task_id)
# Script to call time-related events
class TimeEventScript(DefaultScript):
"""Gametime-sensitive script."""
def at_script_creation(self):
"""The script is created."""
self.start_delay = True
self.persistent = True
# Script attributes
self.db.time_format = None
self.db.event_name = "time"
self.db.number = None
def at_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.
"""
if self.db.time_format:
# If the 'usual' time is set, use it
seconds = self.ndb.usual
if seconds is None:
seconds, usual, details = get_next_wait(self.db.time_format)
self.ndb.usual = usual
if self.interval != seconds:
self.restart(interval=seconds)
if self.db.event_name and self.db.number is not None:
obj = self.obj
if not obj.callbacks:
return
event_name = self.db.event_name
number = self.db.number
obj.callbacks.call(event_name, obj, number=number)
# Functions to manipulate tasks
def complete_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")
except ScriptDB.DoesNotExist:
logger.log_trace("Can't get the event handler.")
return
if task_id not in script.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)

View file

@ -0,0 +1,516 @@
"""
Module containing the test cases for the event system.
"""
from mock import Mock
from textwrap import dedent
from django.conf import settings
from evennia import ScriptDB
from evennia.commands.default.tests import CommandTest
from evennia.objects.objects import ExitCommand
from evennia.utils import ansi, utils
from evennia.utils.create import create_object, create_script
from evennia.utils.test_resources import EvenniaTest
from evennia.contrib.events.commands import CmdCallback
from evennia.contrib.events.callbackhandler import CallbackHandler
# Force settings
settings.EVENTS_CALENDAR = "standard"
# Constants
OLD_EVENTS = {}
class TestEventHandler(EvenniaTest):
"""
Test cases of the event handler to add, edit or delete events.
"""
def setUp(self):
"""Create the event handler."""
super(TestEventHandler, self).setUp()
self.handler = create_script(
"evennia.contrib.events.scripts.EventHandler")
# Copy old events if necessary
if OLD_EVENTS:
self.handler.ndb.events = dict(OLD_EVENTS)
# Alter typeclasses
self.char1.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter")
self.char2.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter")
self.room1.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom")
self.room2.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom")
self.exit.swap_typeclass("evennia.contrib.events.typeclasses.EventExit")
def tearDown(self):
"""Stop the event handler."""
OLD_EVENTS.clear()
OLD_EVENTS.update(self.handler.ndb.events)
self.handler.stop()
CallbackHandler.script = None
super(TestEventHandler, self).tearDown()
def test_start(self):
"""Simply make sure the handler runs with proper initial values."""
self.assertEqual(self.handler.db.callbacks, {})
self.assertEqual(self.handler.db.to_valid, [])
self.assertEqual(self.handler.db.locked, [])
self.assertEqual(self.handler.db.tasks, {})
self.assertIsNotNone(self.handler.ndb.events)
def test_add_validation(self):
"""Add a callback while needing validation."""
author = self.char1
self.handler.add_callback(self.room1, "dummy",
"character.db.strength = 40", author=author, valid=False)
callback = self.handler.get_callbacks(self.room1).get("dummy")
callback = callback[0]
self.assertIsNotNone(callback)
self.assertEqual(callback["author"], author)
self.assertEqual(callback["valid"], False)
# Since this callback is not valid, it should appear in 'to_valid'
self.assertIn((self.room1, "dummy", 0), self.handler.db.to_valid)
# Run this dummy callback (shouldn't do anything)
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(
self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 10)
def test_edit(self):
"""Test editing a callback."""
author = self.char1
self.handler.add_callback(self.room1, "dummy",
"character.db.strength = 60", author=author, valid=True)
# Edit it right away
self.handler.edit_callback(self.room1, "dummy", 0,
"character.db.strength = 65", author=self.char2, valid=True)
# Check that the callback was written
callback = self.handler.get_callbacks(self.room1).get("dummy")
callback = callback[0]
self.assertIsNotNone(callback)
self.assertEqual(callback["author"], author)
self.assertEqual(callback["valid"], True)
self.assertEqual(callback["updated_by"], self.char2)
# Run this dummy callback
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(
self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 65)
def test_edit_validation(self):
"""Edit a callback when validation isn't automatic."""
author = self.char1
self.handler.add_callback(self.room1, "dummy",
"character.db.strength = 70", author=author, valid=True)
# Edit it right away
self.handler.edit_callback(self.room1, "dummy", 0,
"character.db.strength = 80", author=self.char2, valid=False)
# Run this dummy callback (shouldn't do anything)
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(
self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 10)
def test_del(self):
"""Try to delete a callback."""
# Add 3 callbacks
self.handler.add_callback(self.room1, "dummy",
"character.db.strength = 5", author=self.char1, valid=True)
self.handler.add_callback(self.room1, "dummy",
"character.db.strength = 8", author=self.char2, valid=False)
self.handler.add_callback(self.room1, "dummy",
"character.db.strength = 9", author=self.char1, valid=True)
# Note that the second callback isn't valid
self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid)
# Lock the third callback
self.handler.db.locked.append((self.room1, "dummy", 2))
# Delete the first callback
self.handler.del_callback(self.room1, "dummy", 0)
# The callback #1 that was to valid should be #0 now
self.assertIn((self.room1, "dummy", 0), self.handler.db.to_valid)
self.assertNotIn((self.room1, "dummy", 1), self.handler.db.to_valid)
# The lock has been updated too
self.assertIn((self.room1, "dummy", 1), self.handler.db.locked)
self.assertNotIn((self.room1, "dummy", 2), self.handler.db.locked)
# Now delete the first (not valid) callback
self.handler.del_callback(self.room1, "dummy", 0)
self.assertEqual(self.handler.db.to_valid, [])
self.assertIn((self.room1, "dummy", 0), self.handler.db.locked)
self.assertNotIn((self.room1, "dummy", 1), self.handler.db.locked)
# Call the remaining callback
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(
self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 9)
def test_accept(self):
"""Accept an callback."""
# Add 2 callbacks
self.handler.add_callback(self.room1, "dummy",
"character.db.strength = 5", author=self.char1, valid=True)
self.handler.add_callback(self.room1, "dummy",
"character.db.strength = 8", author=self.char2, valid=False)
# Note that the second callback isn't valid
self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid)
# Accept the second callback
self.handler.accept_callback(self.room1, "dummy", 1)
callback = self.handler.get_callbacks(self.room1).get("dummy")
callback = callback[1]
self.assertIsNotNone(callback)
self.assertEqual(callback["valid"], True)
# Call the dummy callback
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(
self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 8)
def test_call(self):
"""Test to call amore complex callback."""
self.char1.key = "one"
self.char2.key = "two"
# Add an callback
code = dedent("""
if character.key == "one":
character.db.health = 50
else:
character.db.health = 0
""".strip("\n"))
self.handler.add_callback(self.room1, "dummy", code,
author=self.char1, valid=True)
# Call the dummy callback
self.assertTrue(self.handler.call(
self.room1, "dummy", locals={"character": self.char1}))
self.assertEqual(self.char1.db.health, 50)
self.assertTrue(self.handler.call(
self.room1, "dummy", locals={"character": self.char2}))
self.assertEqual(self.char2.db.health, 0)
def test_handler(self):
"""Test the object handler."""
self.assertIsNotNone(self.char1.callbacks)
# Add an callback
callback = self.room1.callbacks.add("dummy", "pass", author=self.char1,
valid=True)
self.assertEqual(callback.obj, self.room1)
self.assertEqual(callback.name, "dummy")
self.assertEqual(callback.code, "pass")
self.assertEqual(callback.author, self.char1)
self.assertEqual(callback.valid, True)
self.assertIn([callback], self.room1.callbacks.all().values())
# Edit this very callback
new = self.room1.callbacks.edit("dummy", 0, "character.db.say = True",
author=self.char1, valid=True)
self.assertIn([new], self.room1.callbacks.all().values())
self.assertNotIn([callback], self.room1.callbacks.all().values())
# Try to call this callback
self.assertTrue(self.room1.callbacks.call("dummy",
locals={"character": self.char2}))
self.assertTrue(self.char2.db.say)
# Delete the callback
self.room1.callbacks.remove("dummy", 0)
self.assertEqual(self.room1.callbacks.all(), {})
class TestCmdCallback(CommandTest):
"""Test the @callback command."""
def setUp(self):
"""Create the callback handler."""
super(TestCmdCallback, self).setUp()
self.handler = create_script(
"evennia.contrib.events.scripts.EventHandler")
# Copy old events if necessary
if OLD_EVENTS:
self.handler.ndb.events = dict(OLD_EVENTS)
# Alter typeclasses
self.char1.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter")
self.char2.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter")
self.room1.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom")
self.room2.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom")
self.exit.swap_typeclass("evennia.contrib.events.typeclasses.EventExit")
def tearDown(self):
"""Stop the callback handler."""
OLD_EVENTS.clear()
OLD_EVENTS.update(self.handler.ndb.events)
self.handler.stop()
for script in ScriptDB.objects.filter(
db_typeclass_path="evennia.contrib.events.scripts.TimeEventScript"):
script.stop()
CallbackHandler.script = None
super(TestCmdCallback, self).tearDown()
def test_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)
for line in lines:
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")
def test_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)
def test_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)
def test_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")
def test_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)
class TestDefaultCallbacks(CommandTest):
"""Test the default callbacks."""
def setUp(self):
"""Create the callback handler."""
super(TestDefaultCallbacks, self).setUp()
self.handler = create_script(
"evennia.contrib.events.scripts.EventHandler")
# Copy old events if necessary
if OLD_EVENTS:
self.handler.ndb.events = dict(OLD_EVENTS)
# Alter typeclasses
self.char1.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter")
self.char2.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter")
self.room1.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom")
self.room2.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom")
self.exit.swap_typeclass("evennia.contrib.events.typeclasses.EventExit")
def tearDown(self):
"""Stop the callback handler."""
OLD_EVENTS.clear()
OLD_EVENTS.update(self.handler.ndb.events)
self.handler.stop()
CallbackHandler.script = None
super(TestDefaultCallbacks, self).tearDown()
def test_exit(self):
"""Test the callbacks of an exit."""
self.char1.key = "char1"
code = dedent("""
if character.key == "char1":
character.msg("You can leave.")
else:
character.msg("You cannot leave.")
deny()
""".strip("\n"))
# Enforce self.exit.destination since swapping typeclass lose it
self.exit.destination = self.room2
# Try the can_traverse callback
self.handler.add_callback(self.exit, "can_traverse", code,
author=self.char1, valid=True)
# Have char1 move through the exit
self.call(ExitCommand(), "", "You can leave.", obj=self.exit)
self.assertIs(self.char1.location, self.room2)
# Have char2 move through this exit
self.call(ExitCommand(), "", "You cannot leave.", obj=self.exit,
caller=self.char2)
self.assertIs(self.char2.location, self.room1)
# Try the traverse callback
self.handler.del_callback(self.exit, "can_traverse", 0)
self.handler.add_callback(self.exit, "traverse", "character.msg('Fine!')",
author=self.char1, valid=True)
# Have char2 move through the exit
self.call(ExitCommand(), "", obj=self.exit, caller=self.char2)
self.assertIs(self.char2.location, self.room2)
self.handler.del_callback(self.exit, "traverse", 0)
# Move char1 and char2 back
self.char1.location = self.room1
self.char2.location = self.room1
# Test msg_arrive and msg_leave
code = 'message = "{character} goes out."'
self.handler.add_callback(self.exit, "msg_leave", code,
author=self.char1, valid=True)
# Have char1 move through the exit
old_msg = self.char2.msg
try:
self.char2.msg = Mock()
self.call(ExitCommand(), "", obj=self.exit)
stored_msg = [args[0] if args and args[0] else kwargs.get("text",utils.to_str(kwargs, force_string=True))
for name, args, kwargs in self.char2.msg.mock_calls]
# Get the first element of a tuple if msg received a tuple instead of a string
stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg]
returned_msg = ansi.parse_ansi("\n".join(stored_msg), strip_ansi=True)
self.assertEqual(returned_msg, "char1 goes out.")
finally:
self.char2.msg = old_msg
# Create a return exit
back = create_object("evennia.objects.objects.DefaultExit",
key="in", location=self.room2, destination=self.room1)
code = 'message = "{character} goes in."'
self.handler.add_callback(self.exit, "msg_arrive", code,
author=self.char1, valid=True)
# Have char1 move through the exit
old_msg = self.char2.msg
try:
self.char2.msg = Mock()
self.call(ExitCommand(), "", obj=back)
stored_msg = [args[0] if args and args[0] else kwargs.get("text",utils.to_str(kwargs, force_string=True))
for name, args, kwargs in self.char2.msg.mock_calls]
# Get the first element of a tuple if msg received a tuple instead of a string
stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg]
returned_msg = ansi.parse_ansi("\n".join(stored_msg), strip_ansi=True)
self.assertEqual(returned_msg, "char1 goes in.")
finally:
self.char2.msg = old_msg

View file

@ -0,0 +1,820 @@
"""
Typeclasses for the event system.
To use thm, one should inherit from these classes (EventObject,
EventRoom, EventCharacter and EventExit).
"""
from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom
from evennia import ScriptDB
from evennia.utils.utils import delay, inherits_from, lazy_property
from evennia.contrib.events.callbackhandler import CallbackHandler
from evennia.contrib.events.utils import register_events, time_event, phrase_event
# Character help
CHARACTER_CAN_DELETE = """
Can the character be deleted?
This event is called before the character is deleted. You can use
'deny()' in this event to prevent this character from being deleted.
If this event doesn't prevent the character from being deleted, its
'delete' event is called right away.
Variables you can use in this event:
character: the character connected to this event.
"""
CHARACTER_CAN_MOVE = """
Can the character move?
This event is called before the character moves into another
location. You can prevent the character from moving
using the 'deny()' eventfunc.
Variables you can use in this event:
character: the character connected to this event.
origin: the current location of the character.
destination: the future location of the character.
"""
CHARACTER_CAN_PART = """
Can the departing charaacter leave this room?
This event is called before another character can move from the
location where the current character also is. This event can be
used to prevent someone to leave this room if, for instance, he/she
hasn't paid, or he/she is going to a protected area, past a guard,
and so on. Use 'deny()' to prevent the departing character from
moving.
Variables you can use in this event:
departing: the character who wants to leave this room.
character: the character connected to this event.
"""
CHARACTER_CAN_SAY = """
Before another character can say something in the same location.
This event is called before another character says something in the
character's location. The "something" in question can be modified,
or the action can be prevented by using 'deny()'. To change the
content of what the character says, simply change the variable
'message' to another string of characters.
Variables you can use in this event:
speaker: the character who is using the say command.
character: the character connected to this event.
message: the text spoken by the character.
"""
CHARACTER_DELETE = """
Before deleting the character.
This event is called just before deleting this character. It shouldn't
be prevented (using the `deny()` function at this stage doesn't
have any effect). If you want to prevent deletion of this character,
use the event `can_delete` instead.
Variables you can use in this event:
character: the character connected to this event.
"""
CHARACTER_GREET = """
A new character arrives in the location of this character.
This event is called when another character arrives in the location
where the current character is. For instance, a puppeted character
arrives in the shop of a shopkeeper (assuming the shopkeeper is
a character). As its name suggests, this event can be very useful
to have NPC greeting one another, or players, who come to visit.
Variables you can use in this event:
character: the character connected to this event.
newcomer: the character arriving in the same location.
"""
CHARACTER_MOVE = """
After the character has moved into its new room.
This event is called when the character has moved into a new
room. It is too late to prevent the move at this point.
Variables you can use in this event:
character: the character connected to this event.
origin: the old location of the character.
destination: the new location of the character.
"""
CHARACTER_PUPPETED = """
When the character has been puppeted by a player.
This event is called when a player has just puppeted this character.
This can commonly happen when a player connects onto this character,
or when puppeting to a NPC or free character.
Variables you can use in this event:
character: the character connected to this event.
"""
CHARACTER_SAY = """
After another character has said something in the character's room.
This event is called right after another character has said
something in the same location.. The action cannot be prevented
at this moment. Instead, this event is ideal to create keywords
that would trigger a character (like a NPC) in doing something
if a specific phrase is spoken in the same location.
To use this event, you have to specify a list of keywords as
parameters that should be present, as separate words, in the
spoken phrase. For instance, you can set an event tthat would
fire if the phrase spoken by the character contains "menu" or
"dinner" or "lunch":
@event/add ... = say menu, dinner, lunch
Then if one of the words is present in what the character says,
this event will fire.
Variables you can use in this event:
speaker: the character speaking in this room.
character: the character connected to this event.
message: the text having been spoken by the character.
"""
CHARACTER_TIME = """
A repeated event to be called regularly.
This event is scheduled to repeat at different times, specified
as parameters. You can set it to run every day at 8:00 AM (game
time). You have to specify the time as an argument to @event/add, like:
@event/add here = time 8:00
The parameter (8:00 here) must be a suite of digits separated by
spaces, colons or dashes. Keep it as close from a recognizable
date format, like this:
@event/add here = time 06-15 12:20
This event will fire every year on June the 15th at 12 PM (still
game time). Units have to be specified depending on your set calendar
(ask a developer for more details).
Variables you can use in this event:
character: the character connected to this event.
"""
CHARACTER_UNPUPPETED = """
When the character is about to be un-puppeted.
This event is called when a player is about to un-puppet the
character, which can happen if the player is disconnecting or
changing puppets.
Variables you can use in this event:
character: the character connected to this event.
"""
@register_events
class EventCharacter(DefaultCharacter):
"""Typeclass to represent a character and call event types."""
_events = {
"can_delete": (["character"], CHARACTER_CAN_DELETE),
"can_move": (["character", "origin", "destination"], CHARACTER_CAN_MOVE),
"can_part": (["character", "departing"], CHARACTER_CAN_PART),
"can_say": (["speaker", "character", "message"], CHARACTER_CAN_SAY, phrase_event),
"delete": (["character"], CHARACTER_DELETE),
"greet": (["character", "newcomer"], CHARACTER_GREET),
"move": (["character", "origin", "destination"], CHARACTER_MOVE),
"puppeted": (["character"], CHARACTER_PUPPETED),
"say": (["speaker", "character", "message"], CHARACTER_SAY, phrase_event),
"time": (["character"], CHARACTER_TIME, None, time_event),
"unpuppeted": (["character"], CHARACTER_UNPUPPETED),
}
def announce_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.
"""
if not self.location:
return
string = msg or "{object} is leaving {origin}, heading for {destination}."
# Get the exit from location to destination
location = self.location
exits = [o for o in location.contents if o.location is location and o.destination is destination]
mapping = mapping or {}
mapping.update({
"character": self,
})
if exits:
exits[0].callbacks.call("msg_leave", self, exits[0],
location, destination, string, mapping)
string = exits[0].callbacks.get_variable("message")
mapping = exits[0].callbacks.get_variable("mapping")
# If there's no string, don't display anything
# It can happen if the "message" variable in events is set to None
if not string:
return
super(EventCharacter, self).announce_move_from(destination, msg=string, mapping=mapping)
def announce_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.
"""
if not source_location and self.location.has_player:
# This was created from nowhere and added to a player'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
if source_location:
string = msg or "{character} arrives to {destination} from {origin}."
else:
string = "{character} arrives to {destination}."
origin = source_location
destination = self.location
exits = []
mapping = mapping or {}
mapping.update({
"character": self,
})
if origin:
exits = [o for o in destination.contents if o.location is destination and o.destination is origin]
if exits:
exits[0].callbacks.call("msg_arrive", self, exits[0],
origin, destination, string, mapping)
string = exits[0].callbacks.get_variable("message")
mapping = exits[0].callbacks.get_variable("mapping")
# If there's no string, don't display anything
# It can happen if the "message" variable in events is set to None
if not string:
return
super(EventCharacter, self).announce_move_to(source_location, msg=string, mapping=mapping)
def at_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.
"""
origin = self.location
Room = DefaultRoom
if isinstance(origin, Room) and isinstance(destination, Room):
can = self.callbacks.call("can_move", self,
origin, destination)
if can:
can = origin.callbacks.call("can_move", self, origin)
if can:
# Call other character's 'can_part' event
for present in [o for o in origin.contents if isinstance(
o, DefaultCharacter) and o is not self]:
can = present.callbacks.call("can_part", present, self)
if not can:
break
if can is None:
return True
return can
return True
def at_after_move(self, source_location):
"""
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`.
"""
super(EventCharacter, self).at_after_move(source_location)
origin = source_location
destination = self.location
Room = DefaultRoom
if isinstance(origin, Room) and isinstance(destination, Room):
self.callbacks.call("move", self, origin, destination)
destination.callbacks.call("move", self, origin, destination)
# Call the 'greet' event of characters in the location
for present in [o for o in destination.contents if isinstance(
o, DefaultCharacter) and o is not self]:
present.callbacks.call("greet", present, self)
def at_object_delete(self):
"""
Called just before the database object is permanently
delete()d from the database. If this method returns False,
deletion is aborted.
"""
if not self.callbacks.call("can_delete", self):
return False
self.callbacks.call("delete", self)
return True
def at_post_puppet(self):
"""
Called just after puppeting has been completed and all
Player<->Object links have been established.
Note:
You can use `self.player` and `self.sessions.get()` to get
player and sessions at this point; the last entry in the
list from `self.sessions.get()` is the latest Session
puppeting this Object.
"""
super(EventCharacter, self).at_post_puppet()
self.callbacks.call("puppeted", self)
# Call the room's puppeted_in event
location = self.location
if location and isinstance(location, DefaultRoom):
location.callbacks.call("puppeted_in", self, location)
def at_pre_unpuppet(self):
"""
Called just before beginning to un-connect a puppeting from
this Player.
Note:
You can use `self.player` and `self.sessions.get()` to get
player and sessions at this point; the last entry in the
list from `self.sessions.get()` is the latest Session
puppeting this Object.
"""
self.callbacks.call("unpuppeted", self)
# Call the room's unpuppeted_in event
location = self.location
if location and isinstance(location, DefaultRoom):
location.callbacks.call("unpuppeted_in", self, location)
super(EventCharacter, self).at_pre_unpuppet()
# Exit help
EXIT_CAN_TRAVERSE = """
Can the character traverse through this exit?
This event is called when a character is about to traverse this
exit. You can use the deny() function to deny the character from
exitting for this time.
Variables you can use in this event:
character: the character that wants to traverse this exit.
exit: the exit to be traversed.
room: the room in which stands the character before moving.
"""
EXIT_MSG_ARRIVE = """
Customize the message when a character arrives through this exit.
This event is called when a character arrives through this exit.
To customize the message that will be sent to the room where the
character arrives, change the value of the variable "message"
to give it your custom message. The character itself will not be
notified. You can use mapping between braces, like this:
message = "{character} climbs out of a hole."
In your mapping, you can use {character} (the character who has
arrived), {exit} (the exit), {origin} (the room in which
the character was), and {destination} (the room in which the character
now is). If you need to customize the message with other information,
you can also set "message" to None and send something else instead.
Variables you can use in this event:
character: the character who is arriving through this exit.
exit: the exit having been traversed.
origin: the past location of the character.
destination: the current location of the character.
message: the message to be displayed in the destination.
mapping: a dictionary containing the mapping of the message.
"""
EXIT_MSG_LEAVE = """
Customize the message when a character leaves through this exit.
This event is called when a character leaves through this exit.
To customize the message that will be sent to the room where the
character came from, change the value of the variable "message"
to give it your custom message. The character itself will not be
notified. You can use mapping between braces, like this:
message = "{character} falls into a hole!"
In your mapping, you can use {character} (the character who is
about to leave), {exit} (the exit), {origin} (the room in which
the character is), and {destination} (the room in which the character
is heading for). If you need to customize the message with other
information, you can also set "message" to None and send something
else instead.
Variables you can use in this event:
character: the character who is leaving through this exit.
exit: the exit being traversed.
origin: the location of the character.
destination: the destination of the character.
message: the message to be displayed in the location.
mapping: a dictionary containing additional mapping.
"""
EXIT_TIME = """
A repeated event to be called regularly.
This event is scheduled to repeat at different times, specified
as parameters. You can set it to run every day at 8:00 AM (game
time). You have to specify the time as an argument to @event/add, like:
@event/add north = time 8:00
The parameter (8:00 here) must be a suite of digits separated by
spaces, colons or dashes. Keep it as close from a recognizable
date format, like this:
@event/add south = time 06-15 12:20
This event will fire every year on June the 15th at 12 PM (still
game time). Units have to be specified depending on your set calendar
(ask a developer for more details).
Variables you can use in this event:
exit: the exit connected to this event.
"""
EXIT_TRAVERSE = """
After the characer has traversed through this exit.
This event is called after a character has traversed through this
exit. Traversing cannot be prevented using 'deny()' at this
point. The character will be in a different room and she will
have received the room's description when this event is called.
Variables you can use in this event:
character: the character who has traversed through this exit.
exit: the exit that was just traversed through.
origin: the exit's location (where the character was before moving).
destination: the character's location after moving.
"""
@register_events
class EventExit(DefaultExit):
"""Modified exit including management of events."""
_events = {
"can_traverse": (["character", "exit", "room"], EXIT_CAN_TRAVERSE),
"msg_arrive": (["character", "exit", "origin", "destination", "message", "mapping"], EXIT_MSG_ARRIVE),
"msg_leave": (["character", "exit", "origin", "destination", "message", "mapping"], EXIT_MSG_LEAVE),
"time": (["exit"], EXIT_TIME, None, time_event),
"traverse": (["character", "exit", "origin", "destination"], EXIT_TRAVERSE),
}
def at_traverse(self, traversing_object, target_location):
"""
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.
"""
is_character = inherits_from(traversing_object, DefaultCharacter)
if is_character:
allow = self.callbacks.call("can_traverse", traversing_object,
self, self.location)
if not allow:
return
super(EventExit, self).at_traverse(traversing_object, target_location)
# After traversing
if is_character:
self.callbacks.call("traverse", traversing_object,
self, self.location, self.destination)
# Object help
OBJECT_DROP = """
When a character drops this object.
This event is called when a character drops this object. It is
called after the command has ended and displayed its message, and
the action cannot be prevented at this time.
Variables you can use in this event:
character: the character having dropped the object.
obj: the object connected to this event.
"""
OBJECT_GET = """
When a character gets this object.
This event is called when a character gets this object. It is
called after the command has ended and displayed its message, and
the action cannot be prevented at this time.
Variables you can use in this event:
character: the character having picked up the object.
obj: the object connected to this event.
"""
OBJECT_TIME = """
A repeated event to be called regularly.
This event is scheduled to repeat at different times, specified
as parameters. You can set it to run every day at 8:00 AM (game
time). You have to specify the time as an argument to @event/add, like:
@event/add here = time 8:00
The parameter (8:00 here) must be a suite of digits separated by
spaces, colons or dashes. Keep it as close from a recognizable
date format, like this:
@event/add here = time 06-15 12:20
This event will fire every year on June the 15th at 12 PM (still
game time). Units have to be specified depending on your set calendar
(ask a developer for more details).
Variables you can use in this event:
object: the object connected to this event.
"""
@register_events
class EventObject(DefaultObject):
"""Default object with management of events."""
_events = {
"drop": (["character", "obj"], OBJECT_DROP),
"get": (["character", "obj"], OBJECT_GET),
"time": (["object"], OBJECT_TIME, None, time_event),
}
@lazy_property
def callbacks(self):
"""Return the CallbackHandler."""
return CallbackHandler(self)
def at_get(self, getter):
"""
Called by the default `get` command when this object has been
picked up.
Args:
getter (Object): The object getting this object.
Notes:
This hook cannot stop the pickup from happening. Use
permissions for that.
"""
super(EventObject, self).at_get(getter)
self.callbacks.call("get", getter, self)
def at_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.
Notes:
This hook cannot stop the drop from happening. Use
permissions from that.
"""
super(EventObject, self).at_drop(dropper)
self.callbacks.call("drop", dropper, self)
# Room help
ROOM_CAN_DELETE = """
Can the room be deleted?
This event is called before the room is deleted. You can use
'deny()' in this event to prevent this room from being deleted.
If this event doesn't prevent the room from being deleted, its
'delete' event is called right away.
Variables you can use in this event:
room: the room connected to this event.
"""
ROOM_CAN_MOVE = """
Can the character move into this room?
This event is called before the character can move into this
specific room. You can prevent the move by using the 'deny()'
function.
Variables you can use in this event:
character: the character who wants to move in this room.
room: the room connected to this event.
"""
ROOM_CAN_SAY = """
Before a character can say something in this room.
This event is called before a character says something in this
room. The "something" in question can be modified, or the action
can be prevented by using 'deny()'. To change the content of what
the character says, simply change the variable 'message' to another
string of characters.
Variables you can use in this event:
character: the character who is using the say command.
room: the room connected to this event.
message: the text spoken by the character.
"""
ROOM_DELETE = """
Before deleting the room.
This event is called just before deleting this room. It shouldn't
be prevented (using the `deny()` function at this stage doesn't
have any effect). If you want to prevent deletion of this room,
use the event `can_delete` instead.
Variables you can use in this event:
room: the room connected to this event.
"""
ROOM_MOVE = """
After the character has moved into this room.
This event is called when the character has moved into this
room. It is too late to prevent the move at this point.
Variables you can use in this event:
character: the character connected to this event.
origin: the old location of the character.
destination: the new location of the character.
"""
ROOM_PUPPETED_IN = """
After the character has been puppeted in this room.
This event is called after a character has been puppeted in this
room. This can happen when a player, having connected, begins
to puppet a character. The character's location at this point,
if it's a room, will see this event fire.
Variables you can use in this event:
character: the character who have just been puppeted in this room.
room: the room connected to this event.
"""
ROOM_SAY = """
After the character has said something in the room.
This event is called right after a character has said something
in this room. The action cannot be prevented at this moment.
Instead, this event is ideal to create actions that will respond
to something being said aloud. To use this event, you have to
specify a list of keywords as parameters that should be present,
as separate words, in the spoken phrase. For instance, you can
set an event tthat would fire if the phrase spoken by the character
contains "menu" or "dinner" or "lunch":
@event/add ... = say menu, dinner, lunch
Then if one of the words is present in what the character says,
this event will fire.
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.
"""
ROOM_TIME = """
A repeated event to be called regularly.
This event is scheduled to repeat at different times, specified
as parameters. You can set it to run every day at 8:00 AM (game
time). You have to specify the time as an argument to @event/add, like:
@event/add here = time 8:00
The parameter (8:00 here) must be a suite of digits separated by
spaces, colons or dashes. Keep it as close from a recognizable
date format, like this:
@event/add here = time 06-15 12:20
This event will fire every year on June the 15th at 12 PM (still
game time). Units have to be specified depending on your set calendar
(ask a developer for more details).
Variables you can use in this event:
room: the room connected to this event.
"""
ROOM_UNPUPPETED_IN = """
Before the character is un-puppeted in this room.
This event is called before a character is un-puppeted in this
room. This can happen when a player, puppeting a character, is
disconnecting. The character's location at this point, if it's a
room, will see this event fire.
Variables you can use in this event:
character: the character who is about to be un-puppeted in this room.
room: the room connected to this event.
"""
@register_events
class EventRoom(DefaultRoom):
"""Default room with management of events."""
_events = {
"can_delete": (["room"], ROOM_CAN_DELETE),
"can_move": (["character", "room"], ROOM_CAN_MOVE),
"can_say": (["character", "room", "message"], ROOM_CAN_SAY, phrase_event),
"delete": (["room"], ROOM_DELETE),
"move": (["character", "origin", "destination"], ROOM_MOVE),
"puppeted_in": (["character", "room"], ROOM_PUPPETED_IN),
"say": (["character", "room", "message"], ROOM_SAY, phrase_event),
"time": (["room"], ROOM_TIME, None, time_event),
"unpuppeted_in": (["character", "room"], ROOM_UNPUPPETED_IN),
}
def at_object_delete(self):
"""
Called just before the database object is permanently
delete()d from the database. If this method returns False,
deletion is aborted.
"""
if not self.callbacks.call("can_delete", self):
return False
self.callbacks.call("delete", self)
return True
def at_say(self, speaker, message):
"""
Called on this object if an object inside this object speaks.
The string returned from this method is the final form of the
speech.
Args:
speaker (Object): The object speaking.
message (str): The words spoken.
Returns:
The message to be said (str) or None.
Notes:
You should not need to add things like 'you say: ' or
similar here, that should be handled by the say command before
this.
"""
allow = self.callbacks.call("can_say", speaker, self, message,
parameters=message)
if not allow:
return
message = self.callbacks.get_variable("message")
# Call the event "can_say" of other characters in the location
for present in [o for o in self.contents if isinstance(
o, DefaultCharacter) and o is not speaker]:
allow = present.callbacks.call("can_say", speaker, present,
message, parameters=message)
if not allow:
return
message = present.callbacks.get_variable("message")
# We force the next event to be called after the message
# This will have to change when the Evennia API adds new hooks
delay(0, self.callbacks.call, "say", speaker, self, message,
parameters=message)
for present in [o for o in self.contents if isinstance(
o, DefaultCharacter) and o is not speaker]:
delay(0, present.callbacks.call, "say", speaker, present, message,
parameters=message)
return message

View file

@ -0,0 +1,249 @@
"""
Functions to extend the event system.
These functions are to be used by developers to customize events and callbacks.
"""
from textwrap import dedent
from django.conf import settings
from evennia import logger
from evennia import ScriptDB
from evennia.utils.create import create_script
from evennia.utils.gametime import real_seconds_until as standard_rsu
from evennia.utils.utils import class_from_module
from evennia.contrib.custom_gametime import UNITS
from evennia.contrib.custom_gametime import gametime_to_realtime
from evennia.contrib.custom_gametime import real_seconds_until as custom_rsu
# Temporary storage for events waiting for the script to be started
EVENTS = []
def get_event_handler():
"""Return the event handler or None."""
try:
script = ScriptDB.objects.get(db_key="event_handler")
except ScriptDB.DoesNotExist:
logger.log_trace("Can't get the event handler.")
script = None
return script
def register_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.
"""
if isinstance(path_or_typeclass, basestring):
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")
assert storage.is_active
assert storage.ndb.events is not None
except (ScriptDB.DoesNotExist, AssertionError):
storage = EVENTS
# If the script is started, add the event directly.
# Otherwise, add it to the temporary storage.
for name, tup in getattr(typeclass, "_events", {}).items():
if len(tup) == 4:
variables, help_text, custom_call, custom_add = tup
elif len(tup) == 3:
variables, help_text, custom_call = tup
custom_add = None
elif len(tup) == 2:
variables, help_text = tup
custom_call = None
custom_add = None
else:
variables = help_text = custom_call = custom_add = None
if isinstance(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)
return typeclass
# Custom callbacks for specific event types
def get_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)
if calendar is None:
logger.log_err("A time-related event has been set whereas " \
"the gametime calendar has not been set in the settings.")
return
elif calendar == "standard":
rsu = standard_rsu
units = ["min", "hour", "day", "month", "year"]
elif calendar == "custom":
rsu = custom_rsu
back = dict([(value, name) for name, value in UNITS.items()])
sorted_units = sorted(back.items())
del sorted_units[0]
units = [n for v, n in sorted_units]
params = {}
for delimiter in ("-", ":"):
format = format.replace(delimiter, " ")
pieces = list(reversed(format.split()))
details = []
i = 0
for uname in units:
try:
piece = pieces[i]
except IndexError:
break
if not piece.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))
if i < len(units):
next_unit = units[i + 1]
else:
next_unit = None
i += 1
params["sec"] = 0
details = " ".join(details)
until = rsu(**params)
usual = -1
if next_unit:
kwargs = {next_unit: 1}
usual = gametime_to_realtime(**kwargs)
return until, usual, details
def time_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.events.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
def keyword_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 = []
for callback in callbacks:
keys = callback["parameters"]
if not keys or key in [p.strip().lower() for p in keys.split(",")]:
to_call.append(callback)
return to_call
def phrase_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 = ',.";?!'
for p in punctuations:
phrase = phrase.replace(p, " ")
words = phrase.split()
words = [w.strip("' ") for w in words if w.strip("' ")]
to_call = []
for callback in callbacks:
keys = callback["parameters"]
if not keys or any(key.strip().lower() in words for key in keys.split(",")):
to_call.append(callback)
return to_call
class InterruptEvent(RuntimeError):
"""
Interrupt the current event.
You shouldn't have to use this exception directly, probably use the
`deny()` function that handles it instead.
"""
pass

View file

@ -46,12 +46,13 @@ from evennia import syscmdkeys
from evennia.utils.evmenu import EvMenu
from evennia.utils.utils import random_string_from_module
## Constants
# Constants
RE_VALID_USERNAME = re.compile(r"^[a-z]{3,}$", re.I)
LEN_PASSWD = 6
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
## Menu notes (top-level functions)
# Menu notes (top-level functions)
def start(caller):
"""The user should enter his/her username or NEW to create one.
@ -66,23 +67,17 @@ def start(caller):
text = random_string_from_module(CONNECTION_SCREEN_MODULE)
text += "\n\nEnter your username or |yNEW|n to create a new account."
options = (
{ "key": "",
"goto": "start",
},
{
"key": "new",
"goto": "create_account",
},
{ "key": "quit",
"goto": "quit"
},
{
"key": "_default",
"goto": "username",
},
)
{"key": "",
"goto": "start"},
{"key": "new",
"goto": "create_account"},
{"key": "quit",
"goto": "quit"},
{"key": "_default",
"goto": "username"})
return text, options
def username(caller, string_input):
"""Check that the username leads to an existing player.
@ -100,34 +95,25 @@ def username(caller, string_input):
Try another name or leave empty to go back.
""".strip("\n")).format(string_input)
options = (
{
"key": "",
"goto": "start",
},
{
"key": "_default",
"goto": "username",
},
)
{"key": "",
"goto": "start"},
{"key": "_default",
"goto": "username"})
else:
caller.ndb._menutree.player = player
text = "Enter the password for the {} account.".format(player.name)
# Disables echo for the password
caller.msg("", options={"echo": False})
options = (
{
"key": "",
"exec": lambda caller: caller.msg("", options={"echo": True}),
"goto": "start",
},
{
"key": "_default",
"goto": "ask_password",
},
)
{"key": "",
"exec": lambda caller: caller.msg("", options={"echo": True}),
"goto": "start"},
{"key": "_default",
"goto": "ask_password"})
return text, options
def ask_password(caller, string_input):
"""Ask the user to enter the password to this player.
@ -145,10 +131,10 @@ def ask_password(caller, string_input):
player = menutree.player
password_attempts = menutree.password_attempts \
if hasattr(menutree, "password_attempts") else 0
if hasattr(menutree, "password_attempts") else 0
bans = ServerConfig.objects.conf("server_bans")
banned = bans and (any(tup[0] == player.name.lower() for tup in bans) \
or any(tup[2].match(caller.address) for tup in bans if tup[2]))
banned = bans and (any(tup[0] == player.name.lower() for tup in bans) or
any(tup[2].match(caller.address) for tup in bans if tup[2]))
if not player.check_password(string_input):
# Didn't enter a correct password
@ -156,7 +142,7 @@ def ask_password(caller, string_input):
if password_attempts > 2:
# Too many tries
caller.sessionhandler.disconnect(
caller, "|rToo many failed attempts. Disconnecting.|n")
caller, "|rToo many failed attempts. Disconnecting.|n")
text = ""
options = {}
else:
@ -167,16 +153,11 @@ def ask_password(caller, string_input):
""".strip("\n"))
# Loops on the same node
options = (
{
"key": "",
"exec": lambda caller: caller.msg("", options={"echo": True}),
"goto": "start",
},
{
"key": "_default",
"goto": "ask_password",
},
)
{"key": "",
"exec": lambda caller: caller.msg("", options={"echo": True}),
"goto": "start"},
{"key": "_default",
"goto": "ask_password"})
elif banned:
# This is a banned IP or name!
string = dedent("""
@ -196,6 +177,7 @@ def ask_password(caller, string_input):
return text, options
def create_account(caller):
"""Create a new account.
@ -205,13 +187,11 @@ def create_account(caller):
"""
text = "Enter your new account name."
options = (
{
"key": "_default",
"goto": "create_username",
},
)
{"key": "_default",
"goto": "create_username"},)
return text, options
def create_username(caller, string_input):
"""Prompt to enter a valid username (one that doesnt exist).
@ -231,15 +211,10 @@ def create_username(caller, string_input):
""".strip("\n")).format(string_input)
# Loops on the same node
options = (
{
"key": "",
"goto": "start",
},
{
"key": "_default",
"goto": "create_username",
},
)
{"key": "",
"goto": "start"},
{"key": "_default",
"goto": "create_username"})
elif not RE_VALID_USERNAME.search(string_input):
text = dedent("""
|rThis username isn't valid.|n
@ -248,15 +223,10 @@ def create_username(caller, string_input):
Enter another username or leave blank to go back.
""".strip("\n"))
options = (
{
"key": "",
"goto": "start",
},
{
"key": "_default",
"goto": "create_username",
},
)
{"key": "",
"goto": "start"},
{"key": "_default",
"goto": "create_username"})
else:
# a valid username - continue getting the password
menutree.playername = string_input
@ -265,14 +235,12 @@ def create_username(caller, string_input):
# Redirects to the creation of a password
text = "Enter this account's new password."
options = (
{
"key": "_default",
"goto": "create_password",
},
)
{"key": "_default",
"goto": "create_password"},)
return text, options
def create_password(caller, string_input):
"""Ask the user to create a password.
@ -284,16 +252,11 @@ def create_password(caller, string_input):
menutree = caller.ndb._menutree
text = ""
options = (
{
"key": "",
"exec": lambda caller: caller.msg("", options={"echo": True}),
"goto": "start",
},
{
"key": "_default",
"goto": "create_password",
},
)
{"key": "",
"exec": lambda caller: caller.msg("", options={"echo": True}),
"goto": "start"},
{"key": "_default",
"goto": "create_password"})
password = string_input.strip()
playername = menutree.playername
@ -313,13 +276,13 @@ def create_password(caller, string_input):
permissions = settings.PERMISSION_PLAYER_DEFAULT
typeclass = settings.BASE_CHARACTER_TYPECLASS
new_player = unloggedin._create_player(caller, playername,
password, permissions)
password, permissions)
if new_player:
if settings.MULTISESSION_MODE < 2:
default_home = ObjectDB.objects.get_id(
settings.DEFAULT_HOME)
settings.DEFAULT_HOME)
unloggedin._create_character(caller, new_player,
typeclass, default_home, permissions)
typeclass, default_home, permissions)
except Exception:
# 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
@ -338,11 +301,13 @@ def create_password(caller, string_input):
return text, options
def quit(caller):
caller.sessionhandler.disconnect(caller, "Goodbye! Logging off.")
return "", {}
## Other functions
# Other functions
def _formatter(nodetext, optionstext, caller=None):
"""Do not display the options, only the text.
@ -354,7 +319,8 @@ def _formatter(nodetext, optionstext, caller=None):
"""
return nodetext
## Commands and CmdSets
# Commands and CmdSets
class UnloggedinCmdSet(CmdSet):
"Cmdset for the unloggedin state"

View file

@ -101,20 +101,20 @@ class CmdOpen(default_cmds.CmdOpen):
"""
Simple wrapper for the default CmdOpen.create_exit
"""
# create a new exit as normal
new_exit = super(CmdOpen, self).create_exit(exit_name, location, destination,
exit_aliases=exit_aliases, typeclass=typeclass)
if hasattr(self, "return_exit_already_created"):
# we don't create a return exit if it was already created (because
# we created a door)
del self.return_exit_already_created
return None
# create a new exit as normal
new_exit = super(CmdOpen, self).create_exit(exit_name, location, destination,
exit_aliases=exit_aliases, typeclass=typeclass)
return new_exit
if inherits_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 = super(CmdOpen, self).create_exit(exit_name, destination, location,
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

View file

@ -451,7 +451,97 @@ class TestChargen(CommandTest):
self.assertTrue(self.player.db._character_dbrefs)
self.call(chargen.CmdOOCLook(), "", "You, TestPlayer, are an OOC ghost without form.",caller=self.player)
self.call(chargen.CmdOOCLook(), "testchar", "testchar(", caller=self.player)
# Testing clothing contrib
from evennia.contrib import clothing
from evennia.objects.objects import DefaultRoom
class TestClothingCmd(CommandTest):
def test_clothingcommands(self):
wearer = create_object(clothing.ClothedCharacter, key="Wearer")
friend = create_object(clothing.ClothedCharacter, key="Friend")
room = create_object(DefaultRoom, key="room")
wearer.location = room
friend.location = room
# Make a test hat
test_hat = create_object(clothing.Clothing, key="test hat")
test_hat.db.clothing_type = 'hat'
test_hat.location = wearer
# Make a test scarf
test_scarf = create_object(clothing.Clothing, key="test scarf")
test_scarf.db.clothing_type = 'accessory'
test_scarf.location = wearer
# Test wear command
self.call(clothing.CmdWear(), "", "Usage: wear <obj> [wear style]", caller=wearer)
self.call(clothing.CmdWear(), "hat", "Wearer puts on test hat.", caller=wearer)
self.call(clothing.CmdWear(), "scarf stylishly", "Wearer wears test scarf stylishly.", caller=wearer)
# Test cover command.
self.call(clothing.CmdCover(), "", "Usage: cover <worn clothing> [with] <clothing object>", caller=wearer)
self.call(clothing.CmdCover(), "hat with scarf", "Wearer covers test hat with test scarf.", caller=wearer)
# Test remove command.
self.call(clothing.CmdRemove(), "", "Could not find ''.", caller=wearer)
self.call(clothing.CmdRemove(), "hat", "You have to take off test scarf first.", caller=wearer)
self.call(clothing.CmdRemove(), "scarf", "Wearer removes test scarf, revealing test hat.", caller=wearer)
# Test uncover command.
test_scarf.wear(wearer, True)
test_hat.db.covered_by = test_scarf
self.call(clothing.CmdUncover(), "", "Usage: uncover <worn clothing object>", caller=wearer)
self.call(clothing.CmdUncover(), "hat", "Wearer uncovers test hat.", caller=wearer)
# Test drop command.
test_hat.db.covered_by = test_scarf
self.call(clothing.CmdDrop(), "", "Drop what?", caller=wearer)
self.call(clothing.CmdDrop(), "hat", "You can't drop that because it's covered by test scarf.", caller=wearer)
self.call(clothing.CmdDrop(), "scarf", "You drop test scarf.", caller=wearer)
# Test give command.
self.call(clothing.CmdGive(), "", "Usage: give <inventory object> = <target>", caller=wearer)
self.call(clothing.CmdGive(), "hat = Friend", "Wearer removes test hat.|You give test hat to Friend.", caller=wearer)
# Test inventory command.
self.call(clothing.CmdInventory(), "", "You are not carrying or wearing anything.", caller=wearer)
class TestClothingFunc(EvenniaTest):
def test_clothingfunctions(self):
wearer = create_object(clothing.ClothedCharacter, key="Wearer")
room = create_object(DefaultRoom, key="room")
wearer.location = room
# Make a test hat
test_hat = create_object(clothing.Clothing, key="test hat")
test_hat.db.clothing_type = 'hat'
test_hat.location = wearer
# Make a test shirt
test_shirt = create_object(clothing.Clothing, key="test shirt")
test_shirt.db.clothing_type = 'top'
test_shirt.location = wearer
# Make a test pants
test_pants = create_object(clothing.Clothing, key="test pants")
test_pants.db.clothing_type = 'bottom'
test_pants.location = wearer
test_hat.wear(wearer, 'on the head')
self.assertEqual(test_hat.db.worn, 'on the head')
test_hat.remove(wearer)
self.assertEqual(test_hat.db.worn, False)
test_hat.worn = True
test_hat.at_get(wearer)
self.assertEqual(test_hat.db.worn, False)
clothes_list = [test_shirt, test_hat, test_pants]
self.assertEqual(clothing.order_clothes_list(clothes_list), [test_hat, test_shirt, test_pants])
test_hat.wear(wearer, True)
test_pants.wear(wearer, True)
self.assertEqual(clothing.get_worn_clothes(wearer), [test_hat, test_pants])
self.assertEqual(clothing.clothing_type_count(clothes_list), {'hat':1, 'top':1, 'bottom':1})
self.assertEqual(clothing.single_type_count(clothes_list, 'hat'), 1)
# Testing custom_gametime
from evennia.contrib import custom_gametime
@ -754,4 +844,95 @@ class TestTutorialWorldRooms(CommandTest):
def test_outroroom(self):
create_object(tutrooms.OutroRoom, key="outroroom")
# test turnbattle
from evennia.contrib import turnbattle
from evennia.objects.objects import DefaultRoom
class TestTurnBattleCmd(CommandTest):
# Test combat commands
def test_turnbattlecmd(self):
self.call(turnbattle.CmdFight(), "", "You can't start a fight if you've been defeated!")
self.call(turnbattle.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(turnbattle.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(turnbattle.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(turnbattle.CmdRest(), "", "Char rests to recover HP.")
class TestTurnBattleFunc(EvenniaTest):
# Test combat functions
def test_turnbattlefunc(self):
attacker = create_object(turnbattle.BattleCharacter, key="Attacker")
defender = create_object(turnbattle.BattleCharacter, key="Defender")
testroom = create_object(DefaultRoom, key="Test Room")
attacker.location = testroom
defender.loaction = testroom
# Initiative roll
initiative = turnbattle.roll_init(attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = turnbattle.get_attack(attacker, defender)
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
# Defense roll
defense_roll = turnbattle.get_defense(attacker, defender)
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = turnbattle.get_damage(attacker, defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
defender.db.hp = 10
turnbattle.apply_damage(defender, 3)
self.assertTrue(defender.db.hp == 7)
# Resolve attack
defender.db.hp = 40
turnbattle.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
self.assertTrue(defender.db.hp < 40)
# Combat cleanup
attacker.db.Combat_attribute = True
turnbattle.combat_cleanup(attacker)
self.assertFalse(attacker.db.combat_attribute)
# Is in combat
self.assertFalse(turnbattle.is_in_combat(attacker))
# Set up turn handler script for further tests
attacker.location.scripts.add(turnbattle.TurnHandler)
turnhandler = attacker.db.combat_TurnHandler
self.assertTrue(attacker.db.combat_TurnHandler)
# Force turn order
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
# Test is turn
self.assertTrue(turnbattle.is_turn(attacker))
# Spend actions
attacker.db.Combat_ActionsLeft = 1
turnbattle.spend_action(attacker, 1, action_name="Test")
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(attacker.db.Combat_LastAction == "Test")
# Initialize for combat
attacker.db.Combat_ActionsLeft = 983
turnhandler.initialize_for_combat(attacker)
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(attacker.db.Combat_LastAction == "null")
# Start turn
defender.db.Combat_ActionsLeft = 0
turnhandler.start_turn(defender)
self.assertTrue(defender.db.Combat_ActionsLeft == 1)
# Next turn
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
turnhandler.next_turn()
self.assertTrue(turnhandler.db.turn == 1)
# Turn end check
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
attacker.db.Combat_ActionsLeft = 0
turnhandler.turn_end_check(attacker)
self.assertTrue(turnhandler.db.turn == 1)
# Join fight
joiner = create_object(turnbattle.BattleCharacter, key="Joiner")
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
turnhandler.join_fight(joiner)
self.assertTrue(turnhandler.db.turn == 1)
self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender])
# Remove the script at the end
turnhandler.stop()

View file

@ -0,0 +1,735 @@
"""
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 BattleCharacter object into
your game's character.py module:
from evennia.contrib.turnbattle import BattleCharacter
And change your game's character typeclass to inherit from BattleCharacter
instead of the default:
class Character(BattleCharacter):
Next, import this module into your default_cmdsets.py module:
from evennia.contrib import turnbattle
And add the battle command set to your default command set:
#
# any commands you add below will overload the default ones.
#
self.add(turnbattle.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.
"""
from random import randint
from evennia import DefaultCharacter, Command, default_cmds, DefaultScript
from evennia.commands.default.help import CmdHelp
"""
----------------------------------------------------------------------------
COMBAT FUNCTIONS START HERE
----------------------------------------------------------------------------
"""
def roll_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.
"""
return randint(1, 1000)
def get_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)
return attack_value
def get_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
return defense_value
def get_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)
return damage_value
def apply_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.
if defender.db.hp <= 0:
defender.db.hp = 0
def resolve_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.
if not attack_value:
attack_value = get_attack(attacker, defender)
# Get a defense value from the defender.
if not defense_value:
defense_value = get_defense(attacker, defender)
# If the attack value is lower than the defense value, miss. Otherwise, hit.
if attack_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, announce defeat.
if defender.db.hp <= 0:
attacker.location.msg_contents("%s has been defeated!" % defender)
def combat_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.
"""
for attr in character.attributes.all():
if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'...
character.attributes.remove(key=attr.key) # ...then delete it!
def is_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
"""
if character.db.Combat_TurnHandler:
return True
return False
def is_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]
if character == currentchar:
return True
return False
def spend_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
Kwargs:
action_name (str or None): If a string is given, sets character's last action in
combat to provided string
"""
if action_name:
character.db.Combat_LastAction = action_name
if actions == 'all': # If spending all actions
character.db.Combat_ActionsLeft = 0 # Set actions to 0
else:
character.db.Combat_ActionsLeft -= actions # Use up actions.
if character.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.
"""
----------------------------------------------------------------------------
CHARACTER TYPECLASS
----------------------------------------------------------------------------
"""
class BattleCharacter(DefaultCharacter):
"""
A character able to participate in turn-based combat. Has attributes for current
and maximum HP, and access to combat commands.
"""
def at_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.
"""
def at_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.
if is_in_combat(self):
self.msg("You can't exit a room while in combat!")
return False # Returning false keeps the character from moving.
if self.db.HP <= 0:
self.msg("You can't move, you've been defeated!")
return False
return True
"""
----------------------------------------------------------------------------
COMMANDS START HERE
----------------------------------------------------------------------------
"""
class CmdFight(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"
def func(self):
"""
This performs the actual command.
"""
here = self.caller.location
fighters = []
if not self.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
if is_in_combat(self.caller): # Already in a fight
self.caller.msg("You're already in a fight!")
return
for thing in here.contents: # Test everything in the room to add it to the fight.
if thing.db.HP: # If the object has HP...
fighters.append(thing) # ...then add it to the fight.
if len(fighters) <= 1: # If you're the only able fighter in the room
self.caller.msg("There's nobody here to fight!")
return
if here.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.TurnHandler")
# Remember you'll have to change the path to the script if you copy this code to your own modules!
class CmdAttack(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"
def func(self):
"This performs the actual command."
"Set the attacker to the caller and the defender to the target."
if not is_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
if not is_turn(self.caller): # If it's not your turn, can't attack.
self.caller.msg("You can only do that on your turn.")
return
if not self.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)
if not defender: # No valid target given.
return
if not defender.db.hp: # Target object has no HP left or to begin with
self.caller.msg("You can't fight that!")
return
if attacker == 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.
class CmdPass(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"
def func(self):
"""
This performs the actual command.
"""
if not is_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
if not is_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.
class CmdDisengage(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"
def func(self):
"""
This performs the actual command.
"""
if not is_in_combat(self.caller): # If you're not in combat
self.caller.msg("You can only do that in combat. (see: help fight)")
return
if not is_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.
"""
class CmdRest(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"
def func(self):
"This performs the actual command."
if is_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.
"""
class CmdCombatHelp(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.
def func(self):
if is_in_combat(self.caller) and not self.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
class BattleCmdSet(default_cmds.CharacterCmdSet):
"""
This command set includes all the commmands used in the battle system.
"""
key = "DefaultCharacter"
def at_cmdset_creation(self):
"""
Populates the cmdset
"""
self.add(CmdFight())
self.add(CmdAttack())
self.add(CmdRest())
self.add(CmdPass())
self.add(CmdDisengage())
self.add(CmdCombatHelp())
"""
----------------------------------------------------------------------------
SCRIPTS START HERE
----------------------------------------------------------------------------
"""
class TurnHandler(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.
"""
def at_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."
for object in self.obj.contents:
if object.db.hp:
self.db.fighters.append(object)
# Initialize each fighter for combat
for fighter in self.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.key for obj in self.db.fighters))
# Set up the current turn and turn timeout delay.
self.db.turn = 0
self.db.timer = 30 # 30 seconds
def at_stop(self):
"""
Called at script termination.
"""
for fighter in self.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
def at_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.
if self.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
elif self.db.timer <= 10 and not self.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
def initialize_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
def start_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 = 1 # 1 action per turn.
# 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)
def next_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
for fighter in self.db.fighters:
if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage
disengage_check = False
if disengage_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
for fighter in self.db.fighters:
if fighter.db.HP == 0:
defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
for fighter in self.db.fighters:
if fighter.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.
if self.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 = 30 + 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.
def turn_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
"""
if not character.db.Combat_ActionsLeft: # Character has no actions remaining
self.next_turn()
return
def join_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)

View file

@ -21,17 +21,17 @@ class Command(BaseCommand):
Each Command implements the following methods, called
in this order (only func() is actually required):
- at_pre_command(): If this returns True, execution is aborted.
- at_pre_cmd(): If this returns True, execution is aborted.
- parse(): Should perform any extra parsing needed on self.args
and store the result on self.
- func(): Performs the actual work.
- at_post_command(): Extra actions, often things done after
- at_post_cmd(): Extra actions, often things done after
every command, like prompts.
"""
pass
#------------------------------------------------------------
# -------------------------------------------------------------
#
# The default commands inherit from
#
@ -46,139 +46,140 @@ class Command(BaseCommand):
# the functionality implemented in the parse() method, so be
# careful with what you change.
#
#------------------------------------------------------------
# -------------------------------------------------------------
#from evennia.utils import utils
#class MuxCommand(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.
# from evennia.utils import utils
#
# 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.
# """
# def has_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.
# """
# return super(MuxCommand, self).has_perm(srcobj)
#
# def at_pre_cmd(self):
# """
# This hook is called before self.parse() on all commands
# """
# pass
# class MuxCommand(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.
#
# def at_post_cmd(self):
# """
# This hook is called after the command has finished executing
# (after self.func()).
# """
# pass
# 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.
# """
# def has_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.
# """
# return super(MuxCommand, self).has_perm(srcobj)
#
# def parse(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)
# def at_pre_cmd(self):
# """
# This hook is called before self.parse() on all commands
# """
# pass
#
# 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
# def at_post_cmd(self):
# """
# This hook is called after the command has finished executing
# (after self.func()).
# """
# pass
#
# 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.
# def parse(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)
#
# A MUX command has the following possible syntax:
# 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
#
# name[ with several words][/switch[/switch..]] arg1[,arg2,...] [[=|,] arg[,..]]
# 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.
#
# 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 /.
# A MUX command has the following possible syntax:
#
# 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)]
# name[ with several words][/switch[/switch..]] arg1[,arg2,...] [[=|,] arg[,..]]
#
# All args and list members are stripped of excess whitespace around the
# strings, but case is preserved.
# """
# raw = self.args
# args = raw.strip()
# 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 /.
#
# # split out switches
# switches = []
# if args and len(args) > 1 and args[0] == "/":
# # we have a switch, or a set of switches. These end with a space.
# switches = args[1:].split(None, 1)
# if len(switches) > 1:
# switches, args = switches
# switches = switches.split('/')
# else:
# args = ""
# switches = switches[0].split('/')
# arglist = [arg.strip() for arg in args.split()]
# 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)]
#
# # check for arg1, arg2, ... = argA, argB, ... constructs
# lhs, rhs = args, None
# lhslist, rhslist = [arg.strip() for arg in args.split(',')], []
# if args and '=' in args:
# lhs, rhs = [arg.strip() for arg in args.split('=', 1)]
# lhslist = [arg.strip() for arg in lhs.split(',')]
# rhslist = [arg.strip() for arg in rhs.split(',')]
# All args and list members are stripped of excess whitespace around the
# strings, but case is preserved.
# """
# raw = self.args
# args = raw.strip()
#
# # 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
# # split out switches
# switches = []
# if args and len(args) > 1 and args[0] == "/":
# # we have a switch, or a set of switches. These end with a space.
# switches = args[1:].split(None, 1)
# if len(switches) > 1:
# switches, args = switches
# switches = switches.split('/')
# else:
# args = ""
# switches = switches[0].split('/')
# arglist = [arg.strip() for arg in args.split()]
#
# # if the class has the player_caller property set on itself, we make
# # sure that self.caller is always the player if possible. We also create
# # a special property "character" for the puppeted object, if any. This
# # is convenient for commands defined on the Player only.
# if hasattr(self, "player_caller") and self.player_caller:
# if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"):
# # caller is an Object/Character
# self.character = self.caller
# self.caller = self.caller.player
# elif utils.inherits_from(self.caller, "evennia.players.players.DefaultPlayer"):
# # caller was already a Player
# self.character = self.caller.get_puppet(self.session)
# else:
# self.character = None
# # check for arg1, arg2, ... = argA, argB, ... constructs
# lhs, rhs = args, None
# lhslist, rhslist = [arg.strip() for arg in args.split(',')], []
# if args and '=' in args:
# lhs, rhs = [arg.strip() for arg in args.split('=', 1)]
# lhslist = [arg.strip() for arg in lhs.split(',')]
# rhslist = [arg.strip() for arg in rhs.split(',')]
#
# # 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 player_caller property set on itself, we make
# # sure that self.caller is always the player if possible. We also create
# # a special property "character" for the puppeted object, if any. This
# # is convenient for commands defined on the Player only.
# if hasattr(self, "player_caller") and self.player_caller:
# if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"):
# # caller is an Object/Character
# self.character = self.caller
# self.caller = self.caller.player
# elif utils.inherits_from(self.caller, "evennia.players.players.DefaultPlayer"):
# # caller was already a Player
# self.character = self.caller.get_puppet(self.session)
# else:
# self.character = None

View file

@ -25,9 +25,9 @@ __pycache__
*.restart
*.db3
# Installation-specific.
# Installation-specific.
# For group efforts, comment out some or all of these.
server/conf/settings.py
server/conf/secret_settings.py
server/logs/*.log.*
web/static/*
web/media/*

View file

@ -0,0 +1,17 @@
"""
This file is meant for when you want to share your game dir with
others but don't want to share all details of your specific game
or local server setup. The settings in this file will override those
in settings.py and is in .gitignore by default.
A good guideline when sharing your game dir is that you want your
game to run correctly also without this file and only use this
to override your public, shared settings.
"""
# The secret key is randomly seeded upon creation. It is used to sign
# Django's cookies and should not be publicly known. It should also
# generally not be changed once people have registered with the game
# since it will invalidate their existing sessions.
SECRET_KEY = {secret_key}

View file

@ -19,6 +19,9 @@ paths (path.to.module) should be given relative to the game's root
folder (typeclasses.foo) whereas paths within the Evennia library
needs to be given explicitly (evennia.foo).
If you want to share your game dir, including its settings, you can
put secret game- or server-specific settings in secret_settings.py.
"""
# Use the defaults from Evennia unless explicitly overridden
@ -31,13 +34,11 @@ from evennia.settings_default import *
# This is the name of your game. Make it catchy!
SERVERNAME = {servername}
######################################################################
# Django web features
######################################################################
# The secret key is randomly seeded upon creation. It is used to sign
# Django's cookies. Do not share this with anyone. Changing it will
# log out all active web browsing sessions. Game web client sessions
# may survive.
SECRET_KEY = {secret_key}
######################################################################
# 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."

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-06-06 17:31
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('help', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='helpentry',
name='db_tags',
field=models.ManyToManyField(blank=True, help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag'),
),
]

View file

@ -58,7 +58,7 @@ class HelpEntry(SharedMemoryModel):
# 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, null=True,
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.

View file

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-06-06 17:31
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import re
class Migration(migrations.Migration):
dependencies = [
('objects', '0005_auto_20150403_2339'),
]
operations = [
migrations.AlterField(
model_name='objectdb',
name='db_attributes',
field=models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute'),
),
migrations.AlterField(
model_name='objectdb',
name='db_sessid',
field=models.CharField(help_text=b'csv list of session ids of connected Player, if any.', max_length=32, null=True, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\,\\d+)*\\Z'), code='invalid', message='Enter only digits separated by commas.')], verbose_name=b'session id'),
),
migrations.AlterField(
model_name='objectdb',
name='db_tags',
field=models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag'),
),
]

View file

@ -18,6 +18,7 @@ from builtins import object
from django.conf import settings
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import validate_comma_separated_integer_list
from evennia.typeclasses.models import TypedObject
from evennia.objects.manager import ObjectDBManager
@ -172,7 +173,8 @@ class ObjectDB(TypedObject):
db_player = models.ForeignKey("players.PlayerDB", null=True, verbose_name='player', on_delete=models.SET_NULL,
help_text='a Player connected to this object, if any.')
# the session id associated with this player, if any
db_sessid = models.CommaSeparatedIntegerField(null=True, max_length=32, verbose_name="session id",
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 Player, if any.")
# The location in the game world. Since this one is likely
# to change often, we set this with the 'location' property

View file

@ -58,7 +58,7 @@ class ObjectSessionHandler(object):
self._sessid_cache = list(set(int(val) for val in (self.obj.db_sessid or "").split(",") if val))
if any(sessid for sessid in self._sessid_cache if sessid not in _SESSIONS):
# cache is out of sync with sessionhandler! Only retain the ones in the handler.
self.sessid_cache = [sessid for sessid in self._sessid_cache if sessid in _SESSIONS]
self._sessid_cache = [sessid for sessid in self._sessid_cache if sessid in _SESSIONS]
self.obj.db_sessid = ",".join(str(val) for val in self._sessid_cache)
self.obj.save(update_fields=["db_sessid"])

View file

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-06-06 17:31
from __future__ import unicode_literals
import django.contrib.auth.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('players', '0005_auto_20160905_0902'),
]
operations = [
migrations.AlterField(
model_name='playerdb',
name='db_attributes',
field=models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute'),
),
migrations.AlterField(
model_name='playerdb',
name='db_tags',
field=models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag'),
),
migrations.AlterField(
model_name='playerdb',
name='username',
field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.ASCIIUsernameValidator()], verbose_name='username'),
),
]

View file

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-06-06 17:31
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scripts', '0007_auto_20150403_2339'),
]
operations = [
migrations.AlterField(
model_name='scriptdb',
name='db_attributes',
field=models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute'),
),
migrations.AlterField(
model_name='scriptdb',
name='db_tags',
field=models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag'),
),
]

View file

@ -64,8 +64,8 @@ ENFORCED_SETTING = False
# requirements
PYTHON_MIN = '2.7'
TWISTED_MIN = '16.0.0'
DJANGO_MIN = '1.8'
DJANGO_REC = '1.9'
DJANGO_MIN = '1.11'
DJANGO_REC = '1.11'
sys.path[1] = EVENNIA_ROOT
@ -344,10 +344,13 @@ ERROR_DJANGO_MIN = \
ERROR: Django {dversion} found. Evennia requires version {django_min}
or higher.
Install it with for example `pip install --upgrade django`
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.
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_MIN = \
@ -506,7 +509,7 @@ def create_secret_key():
return secret_key
def create_settings_file(init=True):
def create_settings_file(init=True, secret_settings=False):
"""
Uses the template settings file to build a working settings file.
@ -514,18 +517,27 @@ def create_settings_file(init=True):
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.
"""
settings_path = os.path.join(GAMEDIR, "server", "conf", "settings.py")
if secret_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].capitalize(),
"secret_key": "\'%s\'" % create_secret_key()}
if not init:
# if not --init mode, settings file may already exist from before
if os.path.exists(settings_path):
inp = input("server/conf/settings.py already exists. "
"Do you want to reset it? y/[N]> ")
inp = input("%s already exists. Do you want to reset it? y/[N]> " % settings_path)
if not inp.lower() == 'y':
print ("Aborted.")
sys.exit()
return
else:
print ("Reset the settings file.")
@ -535,12 +547,6 @@ def create_settings_file(init=True):
with open(settings_path, 'r') as f:
settings_string = f.read()
# tweak the settings
setting_dict = {
"settings_default": os.path.join(EVENNIA_LIB, "settings_default.py"),
"servername": "\"%s\"" % GAMEDIR.rsplit(os.path.sep, 1)[1].capitalize(),
"secret_key": "\'%s\'" % create_secret_key()}
settings_string = settings_string.format(**setting_dict)
with open(settings_path, 'w') as f:
@ -564,8 +570,13 @@ def create_game_directory(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)
def create_superuser():

View file

@ -193,7 +193,8 @@ def client_options(session, *args, **kwargs):
"UTF-8", "SCREENREADER", "ENCODING",
"MCCP", "SCREENHEIGHT",
"SCREENWIDTH", "INPUTDEBUG",
"RAW", "NOCOLOR"))
"RAW", "NOCOLOR",
"NOGOAHEAD"))
session.msg(client_options=options)
return
@ -244,6 +245,8 @@ def client_options(session, *args, **kwargs):
flags["NOCOLOR"] = validate_bool(value)
elif key == "raw":
flags["RAW"] = validate_bool(value)
elif key == "nogoahead":
flags["NOGOAHEAD"] = validate_bool(value)
elif key in ('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)

View file

@ -206,7 +206,7 @@ class IRCBot(irc.IRCClient, Session):
reason (str): Motivation for the disconnect.
"""
self.sessionhandler.disconnect(self, reason=reason)
self.sessionhandler.disconnect(self)
self.stopping = True
self.transport.loseConnection()

View file

@ -294,6 +294,7 @@ if SSH_ENABLED:
if WEBSERVER_ENABLED:
from evennia.server.webserver import Website
# Start a reverse proxy to relay data to the Server-side webserver
@ -337,7 +338,7 @@ if WEBSERVER_ENABLED:
websocket_started = True
webclientstr = "\n + webclient%s" % pstring
web_root = server.Site(web_root, logPath=settings.HTTP_LOG_FILE)
web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE)
proxy_service = internet.TCPServer(proxyport,
web_root,
interface=interface)

View file

@ -283,7 +283,7 @@ class SshProtocol(Manhole, session.Session):
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" if text[-1] != "|" else "||n"),
linetosend = ansi.parse_ansi(_RE_N.sub("", text) + ("||n" if text.endswith("|") else "|n"),
strip_ansi=nocolor, xterm256=xterm256, mxp=False)
self.sendLine(linetosend)

View file

@ -0,0 +1,65 @@
"""
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
"""
from builtins import object
SUPPRESS_GA = chr(3)
# default taken from telnet specification
# try to get the customized mssp info, if it exists.
class SuppressGA(object):
"""
Implements the SUPRESS-GO-AHEAD protocol. Add this to a variable on the telnet
protocol to set it up.
"""
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
# tell the client that we prefer to suppress GA ...
self.protocol.will(SUPPRESS_GA).addCallbacks(self.do_suppress_ga, self.dont_suppress_ga)
# ... but also accept if the client really wants not to.
self.protocol.do(SUPPRESS_GA).addCallbacks(self.do_suppress_ga, self.dont_suppress_ga)
def dont_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()
def do_suppress_ga(self, option):
"""
Client wants to suppress GA
Args:
option (Option): Not used.
"""
self.protocol.protocol_flags["NOGOAHEAD"] = True
self.protocol.handshake_done()

View file

@ -13,7 +13,7 @@ from twisted.conch.telnet import Telnet, StatefulTelnetProtocol
from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL
from django.conf import settings
from evennia.server.session import Session
from evennia.server.portal import ttype, mssp, telnet_oob, naws
from evennia.server.portal import ttype, mssp, telnet_oob, naws, suppress_ga
from evennia.server.portal.mccp import Mccp, mccp_compress, MCCP
from evennia.server.portal.mxp import Mxp, mxp_parse
from evennia.utils import ansi
@ -47,9 +47,11 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
client_address = client_address[0] if client_address else None
# this number is counted down for every handshake that completes.
# when it reaches 0 the portal/server syncs their data
self.handshakes = 7 # naws, ttype, mccp, mssp, msdp, gmcp, mxp
self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp
self.init_session(self.protocol_name, client_address, self.factory.sessionhandler)
# suppress go-ahead
self.sga = suppress_ga.SuppressGA(self)
# negotiate client size
self.naws = naws.Naws(self)
# negotiate ttype (client info)
@ -128,7 +130,8 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
option == ttype.TTYPE or
option == naws.NAWS or
option == MCCP or
option == mssp.MSSP)
option == mssp.MSSP or
option == suppress_ga.SUPPRESS_GA)
def enableLocal(self, option):
"""
@ -141,7 +144,9 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
enable (bool): If this option should be enabled.
"""
return option == MCCP or option == ECHO
return (option == MCCP or
option == ECHO or
option == suppress_ga.SUPPRESS_GA)
def disableLocal(self, option):
"""
@ -221,6 +226,8 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
# escape IAC in line mode, and correctly add \r\n
line += self.delimiter
line = line.replace(IAC, IAC + IAC).replace('\n', '\r\n')
if not self.protocol_flags.get("NOGOAHEAD", True):
line += IAC + GA
return self.transport.write(mccp_compress(self, line))
# Session hooks
@ -309,7 +316,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
prompt = text
if not raw:
# processing
prompt = ansi.parse_ansi(_RE_N.sub("", prompt) + ("|n" if prompt[-1] != "|" else "||n"),
prompt = ansi.parse_ansi(_RE_N.sub("", prompt) + ("||n" if prompt.endswith("|") else "|n"),
strip_ansi=nocolor, xterm256=xterm256)
if mxp:
prompt = mxp_parse(prompt)
@ -337,7 +344,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
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" if text[-1] != "|" else "||n"),
linetosend = ansi.parse_ansi(_RE_N.sub("", text) + ("||n" if text.endswith("|") else "|n"),
strip_ansi=nocolor, xterm256=xterm256, mxp=mxp)
if mxp:
linetosend = mxp_parse(linetosend)

View file

@ -47,7 +47,11 @@ IAC = chr(255)
SB = chr(250)
SE = chr(240)
force_str = lambda inp: to_str(inp, force_string=True)
def force_str(inp):
"""Helper to shorten code"""
return to_str(inp, force_string=True)
# pre-compiled regexes
# returns 2-tuple

View file

@ -16,7 +16,7 @@ import os
from twisted.web import static
from twisted.application import internet, service
from twisted.internet import reactor, defer
from twisted.internet.task import LoopingCall
from twisted.internet.task import LoopingCall, deferLater
import django
django.setup()
@ -151,7 +151,8 @@ class Evennia(object):
sys.path.insert(1, '.')
# create a store of services
self.services = service.IServiceCollection(application)
self.services = service.MultiService()
self.services.setServiceParent(application)
self.amp_protocol = None # set by amp factory
self.sessions = SESSIONS
self.sessions.server = self
@ -167,10 +168,21 @@ class Evennia(object):
# initialize channelhandler
channelhandler.CHANNELHANDLER.update()
# set a callback if the server is killed abruptly,
# by Ctrl-C, reboot etc.
reactor.addSystemEventTrigger('before', 'shutdown',
self.shutdown, _reactor_stopping=True)
# 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):
from twisted.internet.defer import Deferred
if hasattr(self, "web_root"):
d = self.web_root.empty_threadpool()
d.addCallback(lambda _: self.shutdown(_reactor_stopping=True))
else:
d = Deferred(lambda _: self.shutdown(_reactor_stopping=True))
d.addCallback(lambda _: reactor.stop())
reactor.callLater(1, d.callback, None)
reactor.sigInt = _wrap_sigint_handler
self.game_running = True
# track the server time
@ -183,7 +195,7 @@ class Evennia(object):
Optimize some SQLite stuff at startup since we
can't save it to the database.
"""
if ((".".join(str(i) for i in django.VERSION) < "1.2" and settings.DATABASE_ENGINE == "sqlite3")
if ((".".join(str(i) for i in django.VERSION) < "1.2" and settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3")
or (hasattr(settings, 'DATABASES')
and settings.DATABASES.get("default", {}).get('ENGINE', None)
== 'django.db.backends.sqlite3')):
@ -383,16 +395,16 @@ class Evennia(object):
# always called, also for a reload
self.at_server_stop()
# if _reactor_stopping is true, reactor does not need to
# be stopped again.
if os.name == 'nt' and os.path.exists(SERVER_PIDFILE):
# for Windows we need to remove pid files manually
os.remove(SERVER_PIDFILE)
if hasattr(self, "web_root"): # not set very first start
yield self.web_root.empty_threadpool()
if not _reactor_stopping:
# this will also send a reactor.stop signal, so we set a
# flag to avoid loops.
self.shutdown_complete = True
# kill the server
self.shutdown_complete = True
reactor.callLater(1, reactor.stop)
# we make sure the proper gametime is saved as late as possible
@ -526,18 +538,20 @@ if WEBSERVER_ENABLED:
# Start a django-compatible webserver.
from twisted.python import threadpool
from evennia.server.webserver import DjangoWebRoot, WSGIWebServer, Website
#from twisted.python import threadpool
from evennia.server.webserver import DjangoWebRoot, WSGIWebServer, Website, LockableThreadPool
# start a thread pool and define the root url (/) as a wsgi resource
# recognized by Django
threads = threadpool.ThreadPool(minthreads=max(1, settings.WEBSERVER_THREADPOOL_LIMITS[0]),
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("media", static.File(settings.MEDIA_ROOT))
# point our static resources to url /static
web_root.putChild("static", static.File(settings.STATIC_ROOT))
EVENNIA.web_root = web_root
if WEB_PLUGINS_MODULE:
# custom overloads

View file

@ -18,6 +18,8 @@ from twisted.internet import reactor
from twisted.application import internet
from twisted.web.proxy import ReverseProxyResource
from twisted.web.server import NOT_DONE_YET
from twisted.python import threadpool
from twisted.internet import defer
from twisted.web.wsgi import WSGIResource
from django.conf import settings
@ -28,6 +30,27 @@ from evennia.utils import logger
_UPSTREAM_IPS = settings.UPSTREAM_IPS
_DEBUG = settings.DEBUG
class LockableThreadPool(threadpool.ThreadPool):
"""
Threadpool that can be locked from accepting new requests.
"""
def __init__(self, *args, **kwargs):
self._accept_new = True
threadpool.ThreadPool.__init__(self, *args, **kwargs)
def lock(self):
self._accept_new = False
def callInThread(self, func, *args, **kwargs):
"""
called in the main reactor thread. Makes sure the pool
is not locked before continuing.
"""
if self._accept_new:
threadpool.ThreadPool.callInThread(self, func, *args, **kwargs)
#
# X-Forwarded-For Handler
#
@ -102,7 +125,7 @@ class EvenniaReverseProxyResource(ReverseProxyResource):
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(lambda f: f.cancel())
request.notifyFinish().addErrback(lambda f: logger.log_trace("%s\nCaught errback in webserver.py:75." % f))
return NOT_DONE_YET
@ -115,8 +138,9 @@ class DjangoWebRoot(resource.Resource):
"""
This creates a web root (/) that Django
understands by tweaking the way
child instancee ars recognized.
child instances are recognized.
"""
def __init__(self, pool):
"""
Setup the django+twisted resource.
@ -125,9 +149,30 @@ class DjangoWebRoot(resource.Resource):
pool (ThreadPool): The twisted threadpool.
"""
self.pool = pool
self._echo_log = True
self._pending_requests = {}
resource.Resource.__init__(self)
self.wsgi_resource = WSGIResource(reactor, pool, WSGIHandler())
def empty_threadpool(self):
"""
Converts our _pending_requests list of deferreds into a DeferredList
Returns:
deflist (DeferredList): Contains all deferreds of pending requests.
"""
self.pool.lock()
if self._pending_requests and self._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))
return defer.DeferredList(self._pending_requests, consumeErrors=True)
def _decrement_requests(self, *args, **kwargs):
self._pending_requests.pop(kwargs.get('deferred', None), None)
def getChild(self, path, request):
"""
To make things work we nudge the url tree to make this the
@ -137,11 +182,22 @@ class DjangoWebRoot(resource.Resource):
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)
deferred = request.notifyFinish()
self._pending_requests[deferred] = deferred
deferred.addBoth(self._decrement_requests, deferred=deferred)
return self.wsgi_resource
#
# Site with deactivateable logging
#
@ -151,11 +207,13 @@ class Website(server.Site):
This class will only log http requests if settings.DEBUG is True.
"""
noisy = False
def log(self, request):
"Conditional logging"
"""Conditional logging"""
if _DEBUG:
server.Site.log(self, request)
#
# Threaded Webserver
#

View file

@ -125,6 +125,10 @@ LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log')
# file sizes down. Turn off to get ever growing log files and never
# loose log info.
CYCLE_LOGFILES = True
# Number of lines to append to rotating channel logs when they rotate
CHANNEL_LOG_NUM_TAIL_LINES = 20
# Max size of channel log files before they rotate
CHANNEL_LOG_ROTATE_SIZE = 1000000
# Local time zone for this installation. All choices can be found here:
# http://www.postgresql.org/docs/8.0/interactive/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE
TIME_ZONE = 'UTC'
@ -224,7 +228,7 @@ IN_GAME_ERRORS = True
# ENGINE - path to the the database backend. Possible choices are:
# 'django.db.backends.sqlite3', (default)
# 'django.db.backends.mysql',
# 'django.db.backends.'postgresql_psycopg2',
# 'django.db.backends.postgresql_psycopg2',
# 'django.db.backends.oracle' (untested).
# NAME - database name, or path to the db file for sqlite3
# USER - db admin (unused in sqlite3)

View file

@ -1,240 +0,0 @@
"""
This is a patch of django.db.models.base.py:__new__, to allow for the
proxy system to allow multiple inheritance when both parents are of
the same base model.
This patch is implemented as per
https://code.djangoproject.com/ticket/11560 and will hopefully be
possible to remove as it gets added to django's main branch.
"""
# django patch imports
import sys
import copy
import warnings
from django.apps import apps
from django.db.models.base import ModelBase, subclass_exception
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.options import Options
from django.core.exceptions import MultipleObjectsReturned, FieldError
from django.apps.config import MODELS_MODULE_NAME
from django.db.models.fields.related import OneToOneField
#/ django patch imports
def patched_new(cls, name, bases, attrs):
"Patched version of __new__"
super_new = super(ModelBase, cls).__new__
# Also ensure initialization is only performed for subclasses of Model
# (excluding Model class itself).
parents = [b for b in bases if isinstance(b, ModelBase)]
if not parents:
return super_new(cls, name, bases, attrs)
# Create the class.
module = attrs.pop('__module__')
new_class = super_new(cls, name, bases, {'__module__': module})
attr_meta = attrs.pop('Meta', None)
abstract = getattr(attr_meta, 'abstract', False)
if not attr_meta:
meta = getattr(new_class, 'Meta', None)
else:
meta = attr_meta
base_meta = getattr(new_class, '_meta', None)
# Look for an application configuration to attach the model to.
app_config = apps.get_containing_app_config(module)
kwargs = {}
if getattr(meta, 'app_label', None) is None:
if app_config is None:
# If the model is imported before the configuration for its
# application is created (#21719), or isn't in an installed
# application (#21680), use the legacy logic to figure out the
# app_label by looking one level up from the package or module
# named 'models'. If no such package or module exists, fall
# back to looking one level up from the module this model is
# defined in.
# For 'django.contrib.sites.models', this would be 'sites'.
# For 'geo.models.places' this would be 'geo'.
if abstract:
kwargs = {"app_label": None}
else:
msg = (
"Model class %s.%s doesn't declare an explicit app_label "
"and either isn't in an application in INSTALLED_APPS or "
"else was imported before its application was loaded. " %
(module, name))
raise RuntimeError(msg)
new_class.add_to_class('_meta', Options(meta, **kwargs))
if not abstract:
new_class.add_to_class(
'DoesNotExist',
subclass_exception(
str('DoesNotExist'),
tuple(x.DoesNotExist for x in parents if hasattr(x, '_meta') and not x._meta.abstract) or (ObjectDoesNotExist,),
module,
attached_to=new_class))
new_class.add_to_class(
'MultipleObjectsReturned',
subclass_exception(
str('MultipleObjectsReturned'),
tuple(x.MultipleObjectsReturned for x in parents if hasattr(x, '_meta') and not x._meta.abstract) or (MultipleObjectsReturned,),
module,
attached_to=new_class))
if base_meta and not base_meta.abstract:
# Non-abstract child classes inherit some attributes from their
# non-abstract parent (unless an ABC comes before it in the
# method resolution order).
if not hasattr(meta, 'ordering'):
new_class._meta.ordering = base_meta.ordering
if not hasattr(meta, 'get_latest_by'):
new_class._meta.get_latest_by = base_meta.get_latest_by
is_proxy = new_class._meta.proxy
# If the model is a proxy, ensure that the base class
# hasn't been swapped out.
if is_proxy and base_meta and base_meta.swapped:
raise TypeError("%s cannot proxy the swapped model '%s'." % (name, base_meta.swapped))
if getattr(new_class, '_default_manager', None):
if not is_proxy:
# Multi-table inheritance doesn't inherit default manager from
# parents.
new_class._default_manager = None
new_class._base_manager = None
else:
# Proxy classes do inherit parent's default manager, if none is
# set explicitly.
new_class._default_manager = new_class._default_manager._copy_to_model(new_class)
new_class._base_manager = new_class._base_manager._copy_to_model(new_class)
# Add all attributes to the class.
for obj_name, obj in attrs.items():
new_class.add_to_class(obj_name, obj)
# All the fields of any type declared on this model
new_fields = (
new_class._meta.local_fields +
new_class._meta.local_many_to_many +
new_class._meta.virtual_fields
)
field_names = set(f.name for f in new_fields)
# Basic setup for proxy models.
if is_proxy:
base = None
for parent in [kls for kls in parents if hasattr(kls, '_meta')]:
if parent._meta.abstract:
if parent._meta.fields:
raise TypeError("Abstract base class containing model fields not permitted for proxy model '%s'." % name)
else:
continue
#if base is not None: # patch
while parent._meta.proxy: # patch
parent = parent._meta.proxy_for_model # patch
if base is not None and base is not parent: # patch
raise TypeError("Proxy model '%s' has more than one non-abstract model base class." % name)
else:
base = parent
if base is None:
raise TypeError("Proxy model '%s' has no non-abstract model base class." % name)
new_class._meta.setup_proxy(base)
new_class._meta.concrete_model = base._meta.concrete_model
else:
new_class._meta.concrete_model = new_class
# Collect the parent links for multi-table inheritance.
parent_links = {}
for base in reversed([new_class] + parents):
# Conceptually equivalent to `if base is Model`.
if not hasattr(base, '_meta'):
continue
# Skip concrete parent classes.
if base != new_class and not base._meta.abstract:
continue
# Locate OneToOneField instances.
for field in base._meta.local_fields:
if isinstance(field, OneToOneField):
parent_links[field.rel.to] = field
# Do the appropriate setup for any model parents.
for base in parents:
original_base = base
if not hasattr(base, '_meta'):
# Things without _meta aren't functional models, so they're
# uninteresting parents.
continue
parent_fields = base._meta.local_fields + base._meta.local_many_to_many
# Check for clashes between locally declared fields and those
# on the base classes (we cannot handle shadowed fields at the
# moment).
for field in parent_fields:
if field.name in field_names:
raise FieldError(
'Local field %r in class %r clashes '
'with field of similar name from '
'base class %r' % (field.name, name, base.__name__)
)
if not base._meta.abstract:
# Concrete classes...
base = base._meta.concrete_model
if base in parent_links:
field = parent_links[base]
elif not is_proxy:
attr_name = '%s_ptr' % base._meta.model_name
field = OneToOneField(base, name=attr_name,
auto_created=True, parent_link=True)
# Only add the ptr field if it's not already present;
# e.g. migrations will already have it specified
if not hasattr(new_class, attr_name):
new_class.add_to_class(attr_name, field)
else:
field = None
new_class._meta.parents[base] = field
else:
# .. and abstract ones.
for field in parent_fields:
new_class.add_to_class(field.name, copy.deepcopy(field))
# Pass any non-abstract parent classes onto child.
new_class._meta.parents.update(base._meta.parents)
# Inherit managers from the abstract base classes.
new_class.copy_managers(base._meta.abstract_managers)
# Proxy models inherit the non-abstract managers from their base,
# unless they have redefined any of them.
if is_proxy:
new_class.copy_managers(original_base._meta.concrete_managers)
# Inherit virtual fields (like GenericForeignKey) from the parent
# class
for field in base._meta.virtual_fields:
if base._meta.abstract and field.name in field_names:
raise FieldError(
'Local field %r in class %r clashes '
'with field of similar name from '
'abstract base class %r' % (field.name, name, base.__name__)
)
new_class.add_to_class(field.name, copy.deepcopy(field))
if abstract:
# Abstract base models can't be instantiated and don't appear in
# the list of models for an app. We do the final setup for them a
# little differently from normal models.
attr_meta.abstract = False
new_class.Meta = attr_meta
return new_class
new_class._prepare()
new_class._meta.apps.register_model(new_class._meta.app_label, new_class)
return new_class

View file

@ -29,6 +29,7 @@ from builtins import object
from django.db.models import signals
from django.db.models.base import ModelBase
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
@ -94,11 +95,7 @@ class TypeclassBase(SharedMemoryModelBase):
attrs["Meta"] = Meta
attrs["Meta"].proxy = True
# patch for django proxy multi-inheritance
# this is a copy of django.db.models.base.__new__
# with a few lines changed as per
# https://code.djangoproject.com/ticket/11560
new_class = patched_new(cls, name, bases, attrs)
new_class = ModelBase.__new__(cls, name, bases, attrs)
# attach signals
signals.post_save.connect(post_save, sender=new_class)
@ -175,9 +172,9 @@ class TypedObject(SharedMemoryModel):
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, null=True,
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, null=True,
db_tags = models.ManyToManyField(Tag,
help_text='tags on this object. Tags are simple string markers to identify, group and alias objects.')
# Database manager
@ -202,7 +199,7 @@ class TypedObject(SharedMemoryModel):
self.__class__ = class_from_module(self.__defaultclasspath__)
except Exception:
log_trace()
self.__class__ = self._meta.proxy_for_model or self.__class__
self.__class__ = self._meta.concrete_model or self.__class__
finally:
self.db_typeclass_path = typeclass_path
elif self.db_typeclass_path:
@ -214,12 +211,12 @@ class TypedObject(SharedMemoryModel):
self.__class__ = class_from_module(self.__defaultclasspath__)
except Exception:
log_trace()
self.__dbclass__ = self._meta.proxy_for_model or self.__class__
self.__dbclass__ = self._meta.concrete_model or self.__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.proxy_for_model or self.__class__
self.__dbclass__ = self._meta.concrete_model or self.__class__
except AttributeError:
err_class = repr(self.__class__)
self.__class__ = class_from_module("evennia.objects.objects.DefaultObject")

View file

@ -49,6 +49,7 @@ import re
from django.conf import settings
from evennia import Command, CmdSet
from evennia.utils import is_iter, fill, dedent, logger, justify, to_str
from evennia.utils.ansi import raw
from evennia.commands import cmdhandler
# we use cmdhandler instead of evennia.syscmdkeys to
@ -274,7 +275,7 @@ class CmdEditorBase(Command):
lstart, lend = cline, cline + 1
linerange = False
if arglist and ':' in arglist[0]:
if arglist and arglist[0].count(':') == 1:
part1, part2 = arglist[0].split(':')
if part1 and part1.isdigit():
lstart = min(max(0, int(part1)) - 1, nlines)
@ -377,9 +378,9 @@ class CmdLineInput(CmdEditorBase):
indent = "off"
self.caller.msg("|b%02i|||n (|g%s|n) %s" % (
cline, indent, line))
cline, indent, raw(line)))
else:
self.caller.msg("|b%02i|||n %s" % (cline, self.args))
self.caller.msg("|b%02i|||n %s" % (cline, raw(self.args)))
class CmdEditorGroup(CmdEditorBase):
@ -425,7 +426,7 @@ class CmdEditorGroup(CmdEditorBase):
editor.display_buffer(linenums=False, options={"raw": True})
elif cmd == ":::":
# Insert single colon alone on a line
editor.update_buffer(editor.buffer + "\n:")
editor.update_buffer([":"] if lstart == 0 else linebuffer + [":"])
if echo_mode:
caller.msg("Single ':' added to buffer.")
elif cmd == ":h":
@ -932,9 +933,9 @@ class EvEditor(object):
footer = "|n" + sep * 10 +\
"[l:%02i w:%03i c:%04i]" % (nlines, nwords, nchars) + sep * 12 + "(:h for help)" + sep * 28
if linenums:
main = "\n".join("|b%02i|||n %s" % (iline + 1 + offset, line) for iline, line in enumerate(lines))
main = "\n".join("|b%02i|||n %s" % (iline + 1 + offset, raw(line)) for iline, line in enumerate(lines))
else:
main = "\n".join(lines)
main = "\n".join([raw(line) for line in lines])
string = "%s\n%s\n%s" % (header, main, footer)
self._caller.msg(string, options=options)

View file

@ -881,9 +881,11 @@ class CmdGetInput(Command):
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.strip() # we strip the ending line break caused by sending
ok = not callback(caller, prompt, result)
ok = not callback(caller, prompt, result, *args, **kwargs)
if ok:
# only clear the state if the callback does not return
# anything
@ -917,7 +919,7 @@ class _Prompt(object):
pass
def get_input(caller, prompt, callback, session=None):
def get_input(caller, prompt, callback, session=None, *args, **kwargs):
"""
This is a helper function for easily request input from
the caller.
@ -942,6 +944,13 @@ def get_input(caller, prompt, callback, session=None):
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.
@ -961,6 +970,12 @@ def get_input(caller, prompt, callback, session=None):
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.
"""
if not callable(callback):
raise RuntimeError("get_input: input callback is not callable.")
@ -968,6 +983,8 @@ def get_input(caller, prompt, callback, session=None):
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)

View file

@ -25,6 +25,12 @@ class SharedMemoryManager(Manager):
key = key[:-len('__exact')]
if key in ('pk', self.model._meta.pk.attname):
inst = self.model.get_cached_instance(kwargs[items[0]])
try:
# we got the item from cache, but if this is a fk, check it's ours
if getattr(inst, str(self.field).split(".")[-1]) != self.instance:
inst = None
except Exception:
pass
if inst is None:
inst = super(SharedMemoryManager, self).get(*args, **kwargs)
return inst

View file

@ -77,7 +77,7 @@ class SharedMemoryModelBase(ModelBase):
"""
# the dbmodel is either the proxy base or ourselves
dbmodel = cls._meta.proxy_for_model if cls._meta.proxy else cls
dbmodel = cls._meta.concrete_model if cls._meta.proxy else cls
cls.__dbclass__ = dbmodel
if not hasattr(dbmodel, "__instance_cache__"):
# we store __instance_cache__ only on the dbmodel base
@ -572,7 +572,7 @@ def cache_size(mb=True):
if not subclasses:
num = len(submodel.get_all_cached_instances())
numtotal[0] += num
classdict[submodel.__name__] = num
classdict[submodel.__dbclass__.__name__] = num
else:
get_recurse(subclasses)
get_recurse(SharedMemoryModel.__subclasses__())

View file

@ -19,12 +19,14 @@ import os
import time
from datetime import datetime
from traceback import format_exc
from twisted.python import log
from twisted.python import log, logfile
from twisted.internet.threads import deferToThread
_LOGDIR = None
_LOG_ROTATE_SIZE = None
_TIMEZONE = None
_CHANNEL_LOG_NUM_TAIL_LINES = None
def timeformat(when=None):
@ -153,6 +155,58 @@ log_depmsg = log_dep
# Arbitrary file logger
class EvenniaLogFile(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_LINES is None:
from django.conf import settings
_CHANNEL_LOG_NUM_TAIL_LINES = settings.CHANNEL_LOG_NUM_TAIL_LINES
num_lines_to_append = _CHANNEL_LOG_NUM_TAIL_LINES
def rotate(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
if not append_tail:
logfile.LogFile.rotate(self)
return
lines = tail_log_file(self.path, 0, self.num_lines_to_append)
logfile.LogFile.rotate(self)
for line in lines:
self.write(line)
def seek(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
"""
return self._file.seek(*args, **kwargs)
def readlines(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.
"""
return self._file.readlines(*args, **kwargs)
_LOG_FILE_HANDLES = {} # holds open log handles
@ -162,10 +216,13 @@ def _open_log_file(filename):
handle. Will create a new file in the log dir if one didn't
exist.
"""
global _LOG_FILE_HANDLES, _LOGDIR
# we delay import of settings to keep logger module as free
# from django as possible.
global _LOG_FILE_HANDLES, _LOGDIR, _LOG_ROTATE_SIZE
if not _LOGDIR:
from django.conf import settings
_LOGDIR = settings.LOG_DIR
_LOG_ROTATE_SIZE = settings.CHANNEL_LOG_ROTATE_SIZE
filename = os.path.join(_LOGDIR, filename)
if filename in _LOG_FILE_HANDLES:
@ -173,7 +230,8 @@ def _open_log_file(filename):
return _LOG_FILE_HANDLES[filename]
else:
try:
filehandle = open(filename, "a+") # append mode + reading
filehandle = EvenniaLogFile.fromFullPath(filename, rotateLength=_LOG_ROTATE_SIZE)
# filehandle = open(filename, "a+") # append mode + reading
_LOG_FILE_HANDLES[filename] = filehandle
return filehandle
except IOError:

View file

@ -22,7 +22,7 @@ from os.path import join as osjoin
from importlib import import_module
from inspect import ismodule, trace, getmembers, getmodule
from collections import defaultdict, OrderedDict
from twisted.internet import threads, reactor
from twisted.internet import threads, reactor, task
from django.conf import settings
from django.utils import timezone
from django.utils.translation import ugettext as _
@ -940,7 +940,7 @@ def delay(timedelay, callback, *args, **kwargs):
specified here.
"""
return reactor.callLater(timedelay, callback, *args, **kwargs)
return task.deferLater(reactor, timedelay, callback, *args, **kwargs)
_TYPECLASSMODELS = None

View file

@ -1,6 +1,6 @@
# Evennia dependencies, for Linux/Mac platforms
django >= 1.8, < 1.10
django > 1.10, < 2.0
twisted >= 16.0.0
mock >= 1.0.1
pillow == 2.9.0