diff --git a/.travis.yml b/.travis.yml index 132122b3d..d97c60975 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 14ce9abbc..433c962b2 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -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) + diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 2684f9e8d..6a6025616 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -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 "" diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 41841584f..40d944305 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -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, 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 diff --git a/evennia/commands/default/player.py b/evennia/commands/default/player.py index daf147018..3bca2c317 100644 --- a/evennia/commands/default/player.py +++ b/evennia/commands/default/player.py @@ -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, diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index ba35e4c55..6cc9f5f6b 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -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 diff --git a/evennia/comms/migrations/0011_auto_20170606_1731.py b/evennia/comms/migrations/0011_auto_20170606_1731.py new file mode 100644 index 000000000..6d99ae85b --- /dev/null +++ b/evennia/comms/migrations/0011_auto_20170606_1731.py @@ -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'), + ), + ] diff --git a/evennia/comms/models.py b/evennia/comms/models.py index 2a2c59ed8..ac1ad2993 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -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() diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 8372b068b..510d9762b 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -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. diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py new file mode 100644 index 000000000..7fc85b7c8 --- /dev/null +++ b/evennia/contrib/clothing.py @@ -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 [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 [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 + + 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 [with] + + 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 [with] ") + 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 + + 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 ") + 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 + + 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 = + + 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 = ") + 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()) diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md new file mode 100644 index 000000000..d72bf1647 --- /dev/null +++ b/evennia/contrib/events/README.md @@ -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 = ` 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 + + 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 ") + 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="") +``` + +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. diff --git a/evennia/contrib/events/__init__.py b/evennia/contrib/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/evennia/contrib/events/callbackhandler.py b/evennia/contrib/events/callbackhandler.py new file mode 100644 index 000000000..9d6c0b111 --- /dev/null +++ b/evennia/contrib/events/callbackhandler.py @@ -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")) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py new file mode 100644 index 000000000..ddc42d42f --- /dev/null +++ b/evennia/contrib/events/commands.py @@ -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 [= ]", + "@call/add = [parameters]", + "@call/edit = [callback number]", + "@call/del = [callback number]", + "@call/tasks [object 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 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.") diff --git a/evennia/contrib/events/eventfuncs.py b/evennia/contrib/events/eventfuncs.py new file mode 100644 index 000000000..cf323771e --- /dev/null +++ b/evennia/contrib/events/eventfuncs.py @@ -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) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py new file mode 100644 index 000000000..a61d308e5 --- /dev/null +++ b/evennia/contrib/events/scripts.py @@ -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 "\", 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 "", 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) diff --git a/evennia/contrib/events/tests.py b/evennia/contrib/events/tests.py new file mode 100644 index 000000000..c2a8bd4b0 --- /dev/null +++ b/evennia/contrib/events/tests.py @@ -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 diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py new file mode 100644 index 000000000..e8b0bdb6f --- /dev/null +++ b/evennia/contrib/events/typeclasses.py @@ -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 diff --git a/evennia/contrib/events/utils.py b/evennia/contrib/events/utils.py new file mode 100644 index 000000000..4380f92b7 --- /dev/null +++ b/evennia/contrib/events/utils.py @@ -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 diff --git a/evennia/contrib/menu_login.py b/evennia/contrib/menu_login.py index 59b85275e..3e885ea11 100644 --- a/evennia/contrib/menu_login.py +++ b/evennia/contrib/menu_login.py @@ -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" diff --git a/evennia/contrib/simpledoor.py b/evennia/contrib/simpledoor.py index ad6cc8b0a..630e1ba64 100644 --- a/evennia/contrib/simpledoor.py +++ b/evennia/contrib/simpledoor.py @@ -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 diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 80644c7bc..71cc105b8 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -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 [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 [with] ", 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 ", 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 = ", 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() diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py new file mode 100644 index 000000000..692656bc3 --- /dev/null +++ b/evennia/contrib/turnbattle.py @@ -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 + + 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 + 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) diff --git a/evennia/game_template/commands/command.py b/evennia/game_template/commands/command.py index 6efb17439..b7308a330 100644 --- a/evennia/game_template/commands/command.py +++ b/evennia/game_template/commands/command.py @@ -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 diff --git a/evennia/game_template/.gitignore b/evennia/game_template/gitignore similarity index 89% rename from evennia/game_template/.gitignore rename to evennia/game_template/gitignore index a0bb68fa8..f5fb6643a 100644 --- a/evennia/game_template/.gitignore +++ b/evennia/game_template/gitignore @@ -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/* diff --git a/evennia/game_template/server/conf/secret_settings.py b/evennia/game_template/server/conf/secret_settings.py new file mode 100644 index 000000000..8ab0dfdb7 --- /dev/null +++ b/evennia/game_template/server/conf/secret_settings.py @@ -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} diff --git a/evennia/game_template/server/conf/settings.py b/evennia/game_template/server/conf/settings.py index 577639844..89898e38b 100644 --- a/evennia/game_template/server/conf/settings.py +++ b/evennia/game_template/server/conf/settings.py @@ -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." diff --git a/evennia/help/migrations/0002_auto_20170606_1731.py b/evennia/help/migrations/0002_auto_20170606_1731.py new file mode 100644 index 000000000..65ab4a5ee --- /dev/null +++ b/evennia/help/migrations/0002_auto_20170606_1731.py @@ -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'), + ), + ] diff --git a/evennia/help/models.py b/evennia/help/models.py index 48c16ed8b..2433963b8 100644 --- a/evennia/help/models.py +++ b/evennia/help/models.py @@ -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. diff --git a/evennia/objects/migrations/0006_auto_20170606_1731.py b/evennia/objects/migrations/0006_auto_20170606_1731.py new file mode 100644 index 000000000..74d48b29b --- /dev/null +++ b/evennia/objects/migrations/0006_auto_20170606_1731.py @@ -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'), + ), + ] diff --git a/evennia/objects/models.py b/evennia/objects/models.py index 009bae0b9..b3b00ce55 100644 --- a/evennia/objects/models.py +++ b/evennia/objects/models.py @@ -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 diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index eed07fe8b..40f5700db 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -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"]) diff --git a/evennia/players/migrations/0006_auto_20170606_1731.py b/evennia/players/migrations/0006_auto_20170606_1731.py new file mode 100644 index 000000000..bff35ca15 --- /dev/null +++ b/evennia/players/migrations/0006_auto_20170606_1731.py @@ -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'), + ), + ] diff --git a/evennia/scripts/migrations/0008_auto_20170606_1731.py b/evennia/scripts/migrations/0008_auto_20170606_1731.py new file mode 100644 index 000000000..b4a7f3201 --- /dev/null +++ b/evennia/scripts/migrations/0008_auto_20170606_1731.py @@ -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'), + ), + ] diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 96db22fd1..28f6c60bd 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -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(): diff --git a/evennia/server/inputfuncs.py b/evennia/server/inputfuncs.py index 17442ba6e..c956db087 100644 --- a/evennia/server/inputfuncs.py +++ b/evennia/server/inputfuncs.py @@ -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) diff --git a/evennia/server/portal/irc.py b/evennia/server/portal/irc.py index f8aa7e43a..e9a53c406 100644 --- a/evennia/server/portal/irc.py +++ b/evennia/server/portal/irc.py @@ -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() diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 3a8fb5338..060796cd7 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -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) diff --git a/evennia/server/portal/ssh.py b/evennia/server/portal/ssh.py index 07e6fe64e..d06114e60 100644 --- a/evennia/server/portal/ssh.py +++ b/evennia/server/portal/ssh.py @@ -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) diff --git a/evennia/server/portal/suppress_ga.py b/evennia/server/portal/suppress_ga.py new file mode 100644 index 000000000..6326a424f --- /dev/null +++ b/evennia/server/portal/suppress_ga.py @@ -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() diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index e9734f14a..d79a6da94 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -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) diff --git a/evennia/server/portal/telnet_oob.py b/evennia/server/portal/telnet_oob.py index 7e450c4aa..c1c2e6fc8 100644 --- a/evennia/server/portal/telnet_oob.py +++ b/evennia/server/portal/telnet_oob.py @@ -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 diff --git a/evennia/server/server.py b/evennia/server/server.py index b5a1aff51..70d07bbea 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -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 diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index e974226f7..ceb072c5c 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -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 # diff --git a/evennia/settings_default.py b/evennia/settings_default.py index c77985b15..70a405f0b 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -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) diff --git a/evennia/typeclasses/django_new_patch.py b/evennia/typeclasses/django_new_patch.py deleted file mode 100644 index 685b16b97..000000000 --- a/evennia/typeclasses/django_new_patch.py +++ /dev/null @@ -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 diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 3cb4af35d..c163a95e4 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -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") diff --git a/evennia/utils/eveditor.py b/evennia/utils/eveditor.py index 83e9e5bf5..d04582c32 100644 --- a/evennia/utils/eveditor.py +++ b/evennia/utils/eveditor.py @@ -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) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 126bf2016..0e766f9cb 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -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) diff --git a/evennia/utils/idmapper/manager.py b/evennia/utils/idmapper/manager.py index 3b792f66e..8d32e0bf1 100644 --- a/evennia/utils/idmapper/manager.py +++ b/evennia/utils/idmapper/manager.py @@ -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 diff --git a/evennia/utils/idmapper/models.py b/evennia/utils/idmapper/models.py index 06e303be1..a28e32980 100644 --- a/evennia/utils/idmapper/models.py +++ b/evennia/utils/idmapper/models.py @@ -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__()) diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index a46c549de..9048c5de1 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -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: diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 5bc3a20fc..f375ec8c4 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -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 diff --git a/requirements.txt b/requirements.txt index d1e4790d4..bbb9e60db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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