From 7ddcae9d32a128f304d0f97ab7d28f702afb866b Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 4 Apr 2017 11:37:36 -0700 Subject: [PATCH 001/133] Add automatic escaping of color codes in the EvEditor --- evennia/utils/eveditor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/evennia/utils/eveditor.py b/evennia/utils/eveditor.py index 83e9e5bf5..ec0a825b1 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 @@ -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): @@ -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) From f8b279d7bbc6538bd926ba8f957f154a6cbb01ca Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Thu, 6 Apr 2017 19:12:40 -0400 Subject: [PATCH 002/133] Adjust docstring to resolve #1300 Also realigns code indent to multiple of 4 after `# ` is removed to uncomment --- evennia/game_template/commands/command.py | 245 +++++++++++----------- 1 file changed, 123 insertions(+), 122 deletions(-) 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 From fab04034080276829953b2f928c7803064a4fccd Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 30 Mar 2017 16:41:47 -0700 Subject: [PATCH 003/133] Added contrib module for turn-based battle system This adds a new contrib file, a module that provides a simple framework for a tabletop style turn based battle system. From the module itself: 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. --- evennia/contrib/turnbattle.py | 519 ++++++++++++++++++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 evennia/contrib/turnbattle.py diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py new file mode 100644 index 000000000..1ae0fb9b4 --- /dev/null +++ b/evennia/contrib/turnbattle.py @@ -0,0 +1,519 @@ +""" +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, add the following to your settings.py file: + +BASE_CHARACTER_TYPECLASS = "evennia.contrib.turnbattle.BattleCharacter" +CMDSET_CHARACTER = "evennia.contrib.turnbattle.CharacterCmdSet" + +If you want to expand upon this system, it's recommended you copy the +code over to your game's modules and import from there instead. +""" + +from evennia import Command +from evennia import default_cmds +from typeclasses.scripts import Script +from typeclasses.characters import Character +from commands.command import Command +from random import randint + +class BattleCharacter(Character): + + def at_object_creation(self): + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + self.db.max_hp = 100 + self.db.hp = self.db.max_hp + pass + def at_before_move(self, destination): + """ + This keeps characters from moving when in combat or at 0 HP. + """ + if is_in_combat(self): + self.caller.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.caller.msg("You can't move, you've been defeated!") + return False + return True + +class CharacterCmdSet(default_cmds.CharacterCmdSet): + """ + Adds combat commands to the default command set. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + super(CharacterCmdSet, self).at_cmdset_creation() + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + +""" +---------------------------------------------------------------------------- +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 + 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) + here.scripts.add("contrib.turnbattle.TurnHandler") # Add a turn handler script to the room, which starts combat. + # 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." + attacker = self.caller + defender = self.caller.search(self.args) + + 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 + + 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) + self.caller.db.Combat_LastAction = "attack" + self.caller.db.Combat_ActionsLeft -= 1 # 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) + self.caller.db.Combat_LastAction = "pass" + self.caller.db.Combat_ActionsLeft = 0 + +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) + self.caller.db.Combat_LastAction = "disengage" # This is checked by the turn handler to end combat if all disengage. + self.caller.db.Combat_ActionsLeft = 0 + +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. + """ + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + +class TurnHandler(Script): + """ + 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 = 1 # Once a second + 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: + combat_cleanup(fighter) #Clean up leftover combat attributes beforehand, just in case. + fighter.db.Combat_ActionsLeft = 1 #Actions remaining - start of turn adds to this, turn ends when it reaches 0 + fighter.db.Combat_TurnHandler = self #Add a reference to this scrip to the character + fighter.db.Combat_LastAction = "null" #Track last action taken in combat + # 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. + + 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 -= 1 # Count down the timer by one second. + + # If the current character has no actions remaining, go to the next turn. + if not currentchar.db.Combat_ActionsLeft: + self.next_turn() + + # Warn the current character if they're about to time out. + if self.db.timer == 10: # 10 seconds left + currentchar.msg("WARNING: About to time out!") + + # Force current character to disengage if timer runs out. + if self.db.timer <= 0: + currentchar.db.Combat_LastAction = "disengage" # Set last action to 'disengage' + currentchar.db.Combat_ActionsLeft = 0 # Set actions remaining to 0 + self.obj.msg_contents("%s's turn timed out!" % currentchar) + self.next_turn() + + 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. + DisengageCheck = True + for fighter in self.db.fighters: + if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage + DisengageCheck = False + if DisengageCheck == True: # 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. + DefeatedCharacters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + DefeatedCharacters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if DefeatedCharacters == (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] + self.db.timer = 30 # Reset the timer. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + start_turn(newchar) # Start the new character's turn. + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + """ + # Inserts the fighter to the turn order 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. + combat_cleanup(fighter) # Clean up leftover combat attributes beforehand, just in case. + fighter.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + fighter.db.Combat_TurnHandler = self # Add a reference to this scrip to the character + fighter.db.Combat_LastAction = "null" # Track last action taken in combat + + + + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + """ + return randint(1,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. + """ + +def start_turn(character): + """ + Readies a character for the start of their turn. + """ + character.db.Combat_ActionsLeft = 1 # 1 action per turn. + """ + 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. + """ + # 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 resolve_attack(attacker, defender): + """ + Resolves an attack and outputs the result. + """ + # Get an attack roll from the attacker. + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + defense_value = get_defense(attacker, defender) + """ + Even though these functions are very simple, separating them out + makes it much easier to make the calculations more involved later. + """ + # 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 get_attack(attacker, defender): + """ + Returns a value for an attack roll. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + """ + 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. + """ + return attack_value + +def get_defense(attacker, defender): + """ + Returns a value for defense for an attack roll to beat. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + """ + As above, this can be expanded upon based on character stats and equipment. + """ + return defense_value + +def get_damage(attacker, defender): + """ + Returns a value for damage. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + """ + Again, this can be expanded upon. + """ + return damage_value + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP. + """ + 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 combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + """ + 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. + """ + if character.db.Combat_TurnHandler: + return True + return False + +def is_turn(character): + """ + Returns true if it's the given character's turn in combat. + """ + turnhandler = character.db.Combat_TurnHandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + if character == currentchar: + return True + return False + From a070ee1971aa39036c079bf3cb9a034f0c8986cb Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 1 Apr 2017 00:30:46 -0700 Subject: [PATCH 004/133] Typo fix & Minor bugfix Fixed a typo and accounted for a rare bug where the turn handler may skip forward two turns if a player acts the same second they would time out. --- evennia/contrib/turnbattle.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 1ae0fb9b4..d4f4bc46a 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -289,7 +289,7 @@ class TurnHandler(Script): for fighter in self.db.fighters: combat_cleanup(fighter) #Clean up leftover combat attributes beforehand, just in case. fighter.db.Combat_ActionsLeft = 1 #Actions remaining - start of turn adds to this, turn ends when it reaches 0 - fighter.db.Combat_TurnHandler = self #Add a reference to this scrip to the character + fighter.db.Combat_TurnHandler = self #Add a reference to this script to the character fighter.db.Combat_LastAction = "null" #Track last action taken in combat # 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. @@ -318,6 +318,7 @@ class TurnHandler(Script): # If the current character has no actions remaining, go to the next turn. if not currentchar.db.Combat_ActionsLeft: self.next_turn() + return # Warn the current character if they're about to time out. if self.db.timer == 10: # 10 seconds left From 8849366ae9cd5a48e40bb78c57710f64e71ed0cc Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 1 Apr 2017 18:29:26 -0700 Subject: [PATCH 005/133] Re-organized and more thoroughly documented Implemented most of Griatch's requested changes, including documentation of every class and function, adherence to Google and Evennia's style guides, re-ordering of methods to show them defined before they are used, and a less intrusive install process. Tested before committing and found to function the same way as before. More changes to come, particularly to rework the turn handler script to be more efficient. --- evennia/contrib/turnbattle.py | 431 +++++++++++++++++++++------------- 1 file changed, 268 insertions(+), 163 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index d4f4bc46a..1679f3da9 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -16,25 +16,254 @@ 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, add the following to your settings.py file: +To install and test, import this module's BattleCharacter object: -BASE_CHARACTER_TYPECLASS = "evennia.contrib.turnbattle.BattleCharacter" -CMDSET_CHARACTER = "evennia.contrib.turnbattle.CharacterCmdSet" + from evennia.contrib.turnbattle import BattleCharacter -If you want to expand upon this system, it's recommended you copy the -code over to your game's modules and import from there instead. +And change your game's character typeclass to inherit from BattleCharacter +instead of the default: + + class Character(BattleCharacter): + +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 evennia import Command -from evennia import default_cmds -from typeclasses.scripts import Script -from typeclasses.characters import Character -from commands.command import Command from random import randint +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript -class BattleCharacter(Character): +""" +---------------------------------------------------------------------------- +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. + """ + return randint(1,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. + """ + +def start_turn(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. + """ + character.db.Combat_ActionsLeft = 1 # 1 action per turn. + """ + 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. + """ + # 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 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. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + """ + 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. + """ + 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. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + """ + As above, this can be expanded upon based on character stats and equipment. + """ + 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. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + """ + Again, this can be expanded upon. + """ + 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): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + """ + # Get an attack roll from the attacker. + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + defense_value = get_defense(attacker, defender) + """ + Even though these functions are very simple, separating them out + makes it much easier to make the calculations more involved later. + """ + # 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 + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS & COMMAND SET STARTS HERE +---------------------------------------------------------------------------- +""" + +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.cmdset.add('contrib.turnbattle.BattleCmdSet') # Add combat commands + 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. @@ -42,13 +271,23 @@ class BattleCharacter(Character): You may want to expand this to include various 'stats' that can be changed at creation and factor into combat calculations. """ - self.db.max_hp = 100 - self.db.hp = self.db.max_hp - pass def at_before_move(self, destination): """ - This keeps characters from moving when in combat or at 0 HP. + 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.caller.msg("You can't exit a room while in combat!") return False # Returning false keeps the character from moving. @@ -57,17 +296,17 @@ class BattleCharacter(Character): return False return True -class CharacterCmdSet(default_cmds.CharacterCmdSet): +class BattleCmdSet(default_cmds.CharacterCmdSet): """ Adds combat commands to the default command set. """ - key = "DefaultCharacter" + key = "BattleCmdSet" def at_cmdset_creation(self): """ Populates the cmdset """ - super(CharacterCmdSet, self).at_cmdset_creation() + super(default_cmds.CharacterCmdSet, self).at_cmdset_creation() self.add(CmdFight()) self.add(CmdAttack()) self.add(CmdRest()) @@ -261,7 +500,7 @@ SCRIPTS START HERE ---------------------------------------------------------------------------- """ -class TurnHandler(Script): +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 @@ -289,7 +528,7 @@ class TurnHandler(Script): for fighter in self.db.fighters: combat_cleanup(fighter) #Clean up leftover combat attributes beforehand, just in case. fighter.db.Combat_ActionsLeft = 1 #Actions remaining - start of turn adds to this, turn ends when it reaches 0 - fighter.db.Combat_TurnHandler = self #Add a reference to this script to the character + fighter.db.Combat_TurnHandler = self #Add a reference to this scrip to the character fighter.db.Combat_LastAction = "null" #Track last action taken in combat # 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. @@ -318,7 +557,6 @@ class TurnHandler(Script): # If the current character has no actions remaining, go to the next turn. if not currentchar.db.Combat_ActionsLeft: self.next_turn() - return # Warn the current character if they're about to time out. if self.db.timer == 10: # 10 seconds left @@ -337,21 +575,21 @@ class TurnHandler(Script): """ # Check to see if every character disengaged as their last action. If so, end combat. - DisengageCheck = True + disengage_check = True for fighter in self.db.fighters: if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage - DisengageCheck = False - if DisengageCheck == True: # All characters have disengaged + disengage_check = False + if disengage_check == True: # 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. - DefeatedCharacters = 0 + defeated_characters = 0 for fighter in self.db.fighters: if fighter.db.HP == 0: - DefeatedCharacters += 1 # Add 1 for every fighter with 0 HP left (defeated) - if DefeatedCharacters == (len(self.db.fighters) - 1): # If only one character isn't defeated + 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 @@ -372,6 +610,9 @@ class TurnHandler(Script): 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 behind whoever's turn it currently is. self.db.fighters.insert(self.db.turn, character) @@ -382,139 +623,3 @@ class TurnHandler(Script): fighter.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 fighter.db.Combat_TurnHandler = self # Add a reference to this scrip to the character fighter.db.Combat_LastAction = "null" # Track last action taken in combat - - - - -""" ----------------------------------------------------------------------------- -COMBAT FUNCTIONS START HERE ----------------------------------------------------------------------------- -""" -def roll_init(character): - """ - Rolls a number between 1-1000 to determine initiative. - """ - return randint(1,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. - """ - -def start_turn(character): - """ - Readies a character for the start of their turn. - """ - character.db.Combat_ActionsLeft = 1 # 1 action per turn. - """ - 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. - """ - # 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 resolve_attack(attacker, defender): - """ - Resolves an attack and outputs the result. - """ - # Get an attack roll from the attacker. - attack_value = get_attack(attacker, defender) - # Get a defense value from the defender. - defense_value = get_defense(attacker, defender) - """ - Even though these functions are very simple, separating them out - makes it much easier to make the calculations more involved later. - """ - # 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 get_attack(attacker, defender): - """ - Returns a value for an attack roll. - """ - # For this example, just return a random integer up to 100. - attack_value = randint(1, 100) - """ - 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. - """ - return attack_value - -def get_defense(attacker, defender): - """ - Returns a value for defense for an attack roll to beat. - """ - # For this example, just return 50, for about a 50/50 chance of hit. - defense_value = 50 - """ - As above, this can be expanded upon based on character stats and equipment. - """ - return defense_value - -def get_damage(attacker, defender): - """ - Returns a value for damage. - """ - # For this example, just generate a number between 15 and 25. - damage_value = randint(15, 25) - """ - Again, this can be expanded upon. - """ - return damage_value - -def apply_damage(defender, damage): - """ - Applies damage to a target, reducing their HP. - """ - 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 combat_cleanup(character): - """ - Cleans up all the temporary combat-related attributes on a character. - """ - 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. - """ - if character.db.Combat_TurnHandler: - return True - return False - -def is_turn(character): - """ - Returns true if it's the given character's turn in combat. - """ - turnhandler = character.db.Combat_TurnHandler - currentchar = turnhandler.db.fighters[turnhandler.db.turn] - if character == currentchar: - return True - return False - From 49cf1220a62ae607d9a2c2778cf53ad2e248ec7f Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 1 Apr 2017 18:32:45 -0700 Subject: [PATCH 006/133] Restore minor fixes Restores some minor fixes I committed and accidentally reverted when I failed to update my local copy of the module. Note to self: start actually using GitHub correctly. --- evennia/contrib/turnbattle.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 1679f3da9..4a9a43e65 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -528,7 +528,7 @@ class TurnHandler(DefaultScript): for fighter in self.db.fighters: combat_cleanup(fighter) #Clean up leftover combat attributes beforehand, just in case. fighter.db.Combat_ActionsLeft = 1 #Actions remaining - start of turn adds to this, turn ends when it reaches 0 - fighter.db.Combat_TurnHandler = self #Add a reference to this scrip to the character + fighter.db.Combat_TurnHandler = self #Add a reference to this script to the character fighter.db.Combat_LastAction = "null" #Track last action taken in combat # 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. @@ -557,6 +557,7 @@ class TurnHandler(DefaultScript): # If the current character has no actions remaining, go to the next turn. if not currentchar.db.Combat_ActionsLeft: self.next_turn() + return # Warn the current character if they're about to time out. if self.db.timer == 10: # 10 seconds left From 4e3b59e486edb8567c6c842d64c0165d558fbdf4 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 1 Apr 2017 20:48:32 -0700 Subject: [PATCH 007/133] Made turn handler script more efficient It turns out that changing the turn handler script to react to turn-ending commands instead of check for the end of the turn every single second was actually super easy. The script's interval has been increased from 1 second to 10 seconds, so it should fire off ten times less often now, AND instantly cycle the turn once a player has used all their actions. --- evennia/contrib/turnbattle.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 4a9a43e65..da951849f 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -403,6 +403,7 @@ class CmdAttack(Command): resolve_attack(attacker, defender) self.caller.db.Combat_LastAction = "attack" self.caller.db.Combat_ActionsLeft -= 1 # Use up one action. + self.caller.db.Combat_TurnHandler.turn_end_check(self.caller) # Signal potential end of turn. class CmdPass(Command): """ @@ -434,6 +435,7 @@ class CmdPass(Command): self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) self.caller.db.Combat_LastAction = "pass" self.caller.db.Combat_ActionsLeft = 0 + self.caller.db.Combat_TurnHandler.turn_end_check(self.caller) # Signal end of turn. class CmdDisengage(Command): """ @@ -466,6 +468,7 @@ class CmdDisengage(Command): self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) self.caller.db.Combat_LastAction = "disengage" # This is checked by the turn handler to end combat if all disengage. self.caller.db.Combat_ActionsLeft = 0 + self.caller.db.Combat_TurnHandler.turn_end_check(self.caller) # Signal end of turn. class CmdRest(Command): """ @@ -517,7 +520,7 @@ class TurnHandler(DefaultScript): Called once, when the script is created. """ self.key = "Combat Turn Handler" - self.interval = 1 # Once a second + self.interval = 10 # Once every 10 seconds self.persistent = True self.db.fighters = [] # Add all fighters in the room with at least 1 HP to the combat." @@ -539,7 +542,7 @@ class TurnHandler(DefaultScript): "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. @@ -552,12 +555,7 @@ class TurnHandler(DefaultScript): Called once every self.interval seconds. """ currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. - self.db.timer -= 1 # Count down the timer by one second. - - # If the current character has no actions remaining, go to the next turn. - if not currentchar.db.Combat_ActionsLeft: - self.next_turn() - return + self.db.timer -= self.interval # Count down the timer. # Warn the current character if they're about to time out. if self.db.timer == 10: # 10 seconds left @@ -569,6 +567,17 @@ class TurnHandler(DefaultScript): currentchar.db.Combat_ActionsLeft = 0 # Set actions remaining to 0 self.obj.msg_contents("%s's turn timed out!" % currentchar) self.next_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 next_turn(self): """ From 9e6627aabd2bd6c8fe8d6d0e21f1885f64fc6f96 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 1 Apr 2017 22:13:08 -0700 Subject: [PATCH 008/133] New functions + Change install method Created a number of new functions for redundant code, including to initialize characters for combat and spending actions - also changed the suggested method of import, having the user import and add the commands. Adding the command set to BattleCharacter at creation was not persistent across reloads. --- evennia/contrib/turnbattle.py | 166 ++++++++++++++++++++-------------- 1 file changed, 97 insertions(+), 69 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index da951849f..0535e04c1 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -16,7 +16,8 @@ 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: +To install and test, import this module's BattleCharacter object into +your game's character.py module: from evennia.contrib.turnbattle import BattleCharacter @@ -24,6 +25,21 @@ And change your game's character typeclass to inherit from BattleCharacter instead of the default: class Character(BattleCharacter): + +Next, import the combat commands into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import CmdFight, CmdAttack, CmdRest, CmdPass, CmdDisengage + +And add the commands to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) 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 @@ -65,25 +81,6 @@ def roll_init(character): This way, characters with a higher dexterity will go first more often. """ -def start_turn(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. - """ - character.db.Combat_ActionsLeft = 1 # 1 action per turn. - """ - 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. - """ - # 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 get_attack(attacker, defender): """ Returns a value for an attack roll. @@ -243,6 +240,29 @@ def is_turn(character): 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. + """ ---------------------------------------------------------------------------- @@ -261,7 +281,6 @@ class BattleCharacter(DefaultCharacter): Called once, when this object is first created. This is the normal hook to overload for most object types. """ - self.cmdset.add('contrib.turnbattle.BattleCmdSet') # Add combat commands self.db.max_hp = 100 # Set maximum HP to 100 self.db.hp = self.db.max_hp # Set current HP to maximum """ @@ -295,24 +314,7 @@ class BattleCharacter(DefaultCharacter): self.caller.msg("You can't move, you've been defeated!") return False return True - -class BattleCmdSet(default_cmds.CharacterCmdSet): - """ - Adds combat commands to the default command set. - """ - key = "BattleCmdSet" - def at_cmdset_creation(self): - """ - Populates the cmdset - """ - super(default_cmds.CharacterCmdSet, self).at_cmdset_creation() - self.add(CmdFight()) - self.add(CmdAttack()) - self.add(CmdRest()) - self.add(CmdPass()) - self.add(CmdDisengage()) - """ ---------------------------------------------------------------------------- COMMANDS START HERE @@ -401,9 +403,7 @@ class CmdAttack(Command): "If everything checks out, call the attack resolving function." resolve_attack(attacker, defender) - self.caller.db.Combat_LastAction = "attack" - self.caller.db.Combat_ActionsLeft -= 1 # Use up one action. - self.caller.db.Combat_TurnHandler.turn_end_check(self.caller) # Signal potential end of turn. + spend_action(self.caller, 1, action_name="attack") # Use up one action. class CmdPass(Command): """ @@ -433,9 +433,7 @@ class CmdPass(Command): return self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) - self.caller.db.Combat_LastAction = "pass" - self.caller.db.Combat_ActionsLeft = 0 - self.caller.db.Combat_TurnHandler.turn_end_check(self.caller) # Signal end of turn. + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. class CmdDisengage(Command): """ @@ -466,9 +464,11 @@ class CmdDisengage(Command): return self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) - self.caller.db.Combat_LastAction = "disengage" # This is checked by the turn handler to end combat if all disengage. - self.caller.db.Combat_ActionsLeft = 0 - self.caller.db.Combat_TurnHandler.turn_end_check(self.caller) # Signal end of turn. + 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): """ @@ -523,23 +523,25 @@ class TurnHandler(DefaultScript): self.interval = 10 # Once every 10 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: - combat_cleanup(fighter) #Clean up leftover combat attributes beforehand, just in case. - fighter.db.Combat_ActionsLeft = 1 #Actions remaining - start of turn adds to this, turn ends when it reaches 0 - fighter.db.Combat_TurnHandler = self #Add a reference to this script to the character - fighter.db.Combat_LastAction = "null" #Track last action taken in combat + self.initialize_for_combat(fighter) + # 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." + + #Set up the current turn and turn timeout delay. self.db.turn = 0 self.db.timer = 30 # 30 seconds @@ -563,21 +565,37 @@ class TurnHandler(DefaultScript): # Force current character to disengage if timer runs out. if self.db.timer <= 0: - currentchar.db.Combat_LastAction = "disengage" # Set last action to 'disengage' - currentchar.db.Combat_ActionsLeft = 0 # Set actions remaining to 0 self.obj.msg_contents("%s's turn timed out!" % currentchar) - self.next_turn() - - def turn_end_check(self, character): + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + + + def initialize_for_combat(self, character): """ - Tests to see if a character's turn is over, and cycles to the next turn if it is. + Prepares a character for combat when starting or entering a fight. + """ + 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 test for end of turn + character (obj): Character to be readied. """ - if not character.db.Combat_ActionsLeft: # Character has no actions remaining - self.next_turn() - return + character.db.Combat_ActionsLeft = 1 # 1 action per turn. + """ + 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. + """ + # 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): """ @@ -613,9 +631,22 @@ class TurnHandler(DefaultScript): 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] - self.db.timer = 30 # Reset the timer. + # Reset the timer. + self.db.timer = 30 + self.interval + self.force_repeat() self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) - start_turn(newchar) # Start the new character's turn. + 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): """ @@ -624,12 +655,9 @@ class TurnHandler(DefaultScript): Args: character (obj): Character to be added to the fight. """ - # Inserts the fighter to the turn order behind whoever's turn it currently is. + # 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. - combat_cleanup(fighter) # Clean up leftover combat attributes beforehand, just in case. - fighter.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 - fighter.db.Combat_TurnHandler = self # Add a reference to this scrip to the character - fighter.db.Combat_LastAction = "null" # Track last action taken in combat + self.initialize_for_combat(character) From c1cbf0420c9743ef779672971d99a97b8386cd62 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 1 Apr 2017 22:15:26 -0700 Subject: [PATCH 009/133] Minor tweaks Fixed the header for the character code section, now that the command set is gone. Also, I forgot to mention, last commit I also fixed a bug where the timer wouldn't give a full 30 seconds for each characters turn if the turn progressed mid-interval. --- evennia/contrib/turnbattle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 0535e04c1..2a434a864 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -266,7 +266,7 @@ def spend_action(character, actions, action_name=None): """ ---------------------------------------------------------------------------- -CHARACTER TYPECLASS & COMMAND SET STARTS HERE +CHARACTER TYPECLASS ---------------------------------------------------------------------------- """ From 0606f2b0d814dc69dd4dc06557c95971b48a395e Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 2 Apr 2017 01:18:15 -0700 Subject: [PATCH 010/133] Updated more documentation to standard Moved docstrings in the middle of code to the 'Notes:' sections of their relevant functions, and added a missing 'Args:' section. --- evennia/contrib/turnbattle.py | 50 +++++++++++++++++------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 2a434a864..fbfd2fe1b 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -68,18 +68,17 @@ def roll_init(character): 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) - """ - 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. - """ def get_attack(attacker, defender): """ @@ -96,14 +95,13 @@ def get_attack(attacker, defender): 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) - """ - 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. - """ return attack_value def get_defense(attacker, defender): @@ -122,12 +120,11 @@ def get_defense(attacker, defender): 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 - """ - As above, this can be expanded upon based on character stats and equipment. - """ return defense_value def get_damage(attacker, defender): @@ -146,12 +143,11 @@ def get_damage(attacker, defender): 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) - """ - Again, this can be expanded upon. - """ return damage_value def apply_damage(defender, damage): @@ -175,15 +171,16 @@ def resolve_attack(attacker, defender): 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. attack_value = get_attack(attacker, defender) # Get a defense value from the defender. defense_value = get_defense(attacker, defender) - """ - Even though these functions are very simple, separating them out - makes it much easier to make the calculations more involved later. - """ # 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)) @@ -572,6 +569,9 @@ class TurnHandler(DefaultScript): 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 From de21883717b8421ae93ab317c4077e39beac64b4 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 2 Apr 2017 03:48:05 -0700 Subject: [PATCH 011/133] Fixed at_before_move() hook on player Fixed some erroneous code in the at_before_move() hook that I must have forgotten to test properly. It now works as intended. --- evennia/contrib/turnbattle.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index fbfd2fe1b..4c41331fa 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -305,10 +305,10 @@ class BattleCharacter(DefaultCharacter): """ # Keep the character from moving if at 0 HP or in combat. if is_in_combat(self): - self.caller.msg("You can't exit a room while in combat!") + 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.caller.msg("You can't move, you've been defeated!") + self.msg("You can't move, you've been defeated!") return False return True From 9073b688a65acdc0d8a8c919dcd3e6655ddd413e Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 2 Apr 2017 15:28:41 -0700 Subject: [PATCH 012/133] Fixed log error + bug with timeout Reworked the way the turn timeout countdown works - it's now less precise but also no longer gives errors. There may be a problem in the Evennia codebase with the default script object's force_repeat() method, but I was unable to figure out what. --- evennia/contrib/turnbattle.py | 37 ++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 4c41331fa..54b4d0a60 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -517,7 +517,7 @@ class TurnHandler(DefaultScript): Called once, when the script is created. """ self.key = "Combat Turn Handler" - self.interval = 10 # Once every 10 seconds + self.interval = 5 # Once every 5 seconds self.persistent = True self.db.fighters = [] @@ -556,14 +556,15 @@ class TurnHandler(DefaultScript): currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. self.db.timer -= self.interval # Count down the timer. - # Warn the current character if they're about to time out. - if self.db.timer == 10: # 10 seconds left - currentchar.msg("WARNING: About to time out!") - - # Force current character to disengage if timer runs out. 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): @@ -584,16 +585,16 @@ class TurnHandler(DefaultScript): available actions and notifying them that their turn has come up. Args: - character (obj): Character to be readied. + 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. - """ - 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. - """ # 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) @@ -630,12 +631,12 @@ class TurnHandler(DefaultScript): 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] - # Reset the timer. - self.db.timer = 30 + self.interval - self.force_repeat() + 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): """ From fba536e97916c2a2ca2cfa9c03706c206a0e9d19 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Tue, 4 Apr 2017 17:14:44 -0700 Subject: [PATCH 013/133] Battle commands merged into command set I merged all the battle commands into a command set together, which makes the module easier to install. The installation instructions have been changed to reflect this. --- evennia/contrib/turnbattle.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 54b4d0a60..014873865 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -26,20 +26,16 @@ instead of the default: class Character(BattleCharacter): -Next, import the combat commands into your default_cmdsets.py module: +Next, import this module into your default_cmdsets.py module: - from evennia.contrib.turnbattle import CmdFight, CmdAttack, CmdRest, CmdPass, CmdDisengage + from evennia.contrib import turnbattle -And add the commands to your default command set: +And add the battle command set to your default command set: # # any commands you add below will overload the default ones. # - self.add(CmdFight()) - self.add(CmdAttack()) - self.add(CmdRest()) - self.add(CmdPass()) - self.add(CmdDisengage()) + 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 @@ -494,6 +490,22 @@ class CmdRest(Command): You'll probably want to replace this with your own system for recovering HP. """ +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()) + """ ---------------------------------------------------------------------------- SCRIPTS START HERE From 4b113cb6c83ead73650c10f28670b5382eb86adb Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Tue, 4 Apr 2017 17:44:35 -0700 Subject: [PATCH 014/133] Added combat help, fixed oversight in CmdFight Added a new command, 'CmdCombatHelp', which inherits from the default help command. Its behavior is identical to the normal 'help' command, except that it'll return a short list of combat commands when used in combat with no arguments. If there's a better way to implement this functionality, let me know, since it did involve copy-pasting a huge chunk of code from the Help command just to change one thing! I also fixed a glaring oversight where using the 'fight' command while already in a fight would start a second fight. --- evennia/contrib/turnbattle.py | 107 ++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 014873865..18b0a204c 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -44,6 +44,7 @@ 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, _loadhelp, _savehelp, HelpEntry, defaultdict """ ---------------------------------------------------------------------------- @@ -337,6 +338,9 @@ class CmdFight(Command): 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. @@ -489,6 +493,108 @@ class CmdRest(Command): """ 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): + """ + Run the dynamic help entry creator. + """ + query, cmdset = self.args, self.cmdset + caller = self.caller + + suggestion_cutoff = 0.6 + suggestion_maxnum = 5 + + if not query: + # Return quick list of combat command when used in combat with no arguments. + if is_in_combat(self.caller): + 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. + """) + return + else: + query = "all" + + # removing doublets in cmdset, caused by cmdhandler + # having to allow doublet commands to manage exits etc. + cmdset.make_unique(caller) + + # retrieve all available commands and database topics + all_cmds = [cmd for cmd in cmdset if self.check_show_help(cmd, caller)] + all_topics = [topic for topic in HelpEntry.objects.all() if topic.access(caller, 'view', default=True)] + all_categories = list(set([cmd.help_category.lower() for cmd in all_cmds] + [topic.help_category.lower() for topic in all_topics])) + + if query in ("list", "all"): + # we want to list all available help entries, grouped by category + hdict_cmd = defaultdict(list) + hdict_topic = defaultdict(list) + # create the dictionaries {category:[topic, topic ...]} required by format_help_list + # Filter commands that should be reached by the help + # system, but not be displayed in the table. + for cmd in all_cmds: + if self.should_list_cmd(cmd, caller): + hdict_cmd[cmd.help_category].append(cmd.key) + [hdict_topic[topic.help_category].append(topic.key) for topic in all_topics] + # report back + self.msg_help(self.format_help_list(hdict_cmd, hdict_topic)) + return + + # Try to access a particular command + + # build vocabulary of suggestions and rate them by string similarity. + vocabulary = [cmd.key for cmd in all_cmds if cmd] + [topic.key for topic in all_topics] + all_categories + [vocabulary.extend(cmd.aliases) for cmd in all_cmds] + suggestions = [sugg for sugg in string_suggestions(query, set(vocabulary), cutoff=suggestion_cutoff, maxnum=suggestion_maxnum) + if sugg != query] + if not suggestions: + suggestions = [sugg for sugg in vocabulary if sugg != query and sugg.startswith(query)] + + # try an exact command auto-help match + match = [cmd for cmd in all_cmds if cmd == query] + if len(match) == 1: + formatted = self.format_help_entry(match[0].key, + match[0].get_help(caller, cmdset), + aliases=match[0].aliases, + suggested=suggestions) + self.msg_help(formatted) + return + + # try an exact database help entry match + match = list(HelpEntry.objects.find_topicmatch(query, exact=True)) + if len(match) == 1: + formatted = self.format_help_entry(match[0].key, + match[0].entrytext, + aliases=match[0].aliases.all(), + suggested=suggestions) + self.msg_help(formatted) + return + + # try to see if a category name was entered + if query in all_categories: + self.msg_help(self.format_help_list({query:[cmd.key for cmd in all_cmds if cmd.help_category==query]}, + {query:[topic.key for topic in all_topics if topic.help_category==query]})) + return + + # no exact matches found. Just give suggestions. + self.msg(self.format_help_entry("", "No help entry found for '%s'" % query, None, suggested=suggestions)) class BattleCmdSet(default_cmds.CharacterCmdSet): """ @@ -505,6 +611,7 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdRest()) self.add(CmdPass()) self.add(CmdDisengage()) + self.add(CmdCombatHelp()) """ ---------------------------------------------------------------------------- From 4e2a927bb4813aac0e320018b51b1322706ce572 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Tue, 4 Apr 2017 17:53:06 -0700 Subject: [PATCH 015/133] Properly implemented joining a fight in progress Some of the code for joining a fight in progress was imported from my project, The World of Cool Battles, but was not implemented completely. The turn handler script now properly puts a reference to itself in the current fight's room, and joining a combat in progress that one isn't already a part of now works properly. --- evennia/contrib/turnbattle.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 18b0a204c..74e94edfc 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -204,6 +204,7 @@ def combat_cleanup(character): 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! + character.location.db.Combat_TurnHandler = None # Remove reference to turn handler in location def is_in_combat(character): """ @@ -648,6 +649,9 @@ class TurnHandler(DefaultScript): # 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. From 2b0d657314fe8852759c8286d68cfa1f94ae2183 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Tue, 4 Apr 2017 18:22:06 -0700 Subject: [PATCH 016/133] Better combat help + tweak to combat cleanup With the help of Cloud_Keeper and vincent_lg in chat, I made the combat help command work much better - it doesn't copy swaths of code from the original help command anymore, and instead simply calls the default help with super() when the conditions for combat help aren't met. I also moved a line from combat cleanup to the turn handler script, since combat cleanup is called when a character is initialized in combat and could remove the needed reference to the turn handler on the room prematurely, resulting in various errors if more than one fighter joined a fight in progress. --- evennia/contrib/turnbattle.py | 96 ++++------------------------------- 1 file changed, 9 insertions(+), 87 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 74e94edfc..de8cdcad2 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -44,7 +44,7 @@ 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, _loadhelp, _savehelp, HelpEntry, defaultdict +from evennia.commands.default.help import CmdHelp """ ---------------------------------------------------------------------------- @@ -204,7 +204,6 @@ def combat_cleanup(character): 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! - character.location.db.Combat_TurnHandler = None # Remove reference to turn handler in location def is_in_combat(character): """ @@ -511,91 +510,13 @@ class CmdCombatHelp(CmdHelp): # tips on combat when used in a fight with no arguments. def func(self): - """ - Run the dynamic help entry creator. - """ - query, cmdset = self.args, self.cmdset - caller = self.caller - - suggestion_cutoff = 0.6 - suggestion_maxnum = 5 - - if not query: - # Return quick list of combat command when used in combat with no arguments. - if is_in_combat(self.caller): - 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. - """) - return - else: - query = "all" - - # removing doublets in cmdset, caused by cmdhandler - # having to allow doublet commands to manage exits etc. - cmdset.make_unique(caller) - - # retrieve all available commands and database topics - all_cmds = [cmd for cmd in cmdset if self.check_show_help(cmd, caller)] - all_topics = [topic for topic in HelpEntry.objects.all() if topic.access(caller, 'view', default=True)] - all_categories = list(set([cmd.help_category.lower() for cmd in all_cmds] + [topic.help_category.lower() for topic in all_topics])) - - if query in ("list", "all"): - # we want to list all available help entries, grouped by category - hdict_cmd = defaultdict(list) - hdict_topic = defaultdict(list) - # create the dictionaries {category:[topic, topic ...]} required by format_help_list - # Filter commands that should be reached by the help - # system, but not be displayed in the table. - for cmd in all_cmds: - if self.should_list_cmd(cmd, caller): - hdict_cmd[cmd.help_category].append(cmd.key) - [hdict_topic[topic.help_category].append(topic.key) for topic in all_topics] - # report back - self.msg_help(self.format_help_list(hdict_cmd, hdict_topic)) - return - - # Try to access a particular command - - # build vocabulary of suggestions and rate them by string similarity. - vocabulary = [cmd.key for cmd in all_cmds if cmd] + [topic.key for topic in all_topics] + all_categories - [vocabulary.extend(cmd.aliases) for cmd in all_cmds] - suggestions = [sugg for sugg in string_suggestions(query, set(vocabulary), cutoff=suggestion_cutoff, maxnum=suggestion_maxnum) - if sugg != query] - if not suggestions: - suggestions = [sugg for sugg in vocabulary if sugg != query and sugg.startswith(query)] - - # try an exact command auto-help match - match = [cmd for cmd in all_cmds if cmd == query] - if len(match) == 1: - formatted = self.format_help_entry(match[0].key, - match[0].get_help(caller, cmdset), - aliases=match[0].aliases, - suggested=suggestions) - self.msg_help(formatted) - return - - # try an exact database help entry match - match = list(HelpEntry.objects.find_topicmatch(query, exact=True)) - if len(match) == 1: - formatted = self.format_help_entry(match[0].key, - match[0].entrytext, - aliases=match[0].aliases.all(), - suggested=suggestions) - self.msg_help(formatted) - return - - # try to see if a category name was entered - if query in all_categories: - self.msg_help(self.format_help_list({query:[cmd.key for cmd in all_cmds if cmd.help_category==query]}, - {query:[topic.key for topic in all_topics if topic.help_category==query]})) - return - - # no exact matches found. Just give suggestions. - self.msg(self.format_help_entry("", "No help entry found for '%s'" % query, None, suggested=suggestions)) + 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): """ @@ -671,6 +592,7 @@ class TurnHandler(DefaultScript): """ 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): """ From 334eaf692923f25d73b16a265949bdc6207475ae Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 7 Apr 2017 10:52:09 -0700 Subject: [PATCH 017/133] Slight tweak to CmdAttack Changed the order of the code in CmdAttack, so that it does not try to search for a target until it's established that it's a valid time to attack. This is so it doesn't raise a 'not found' error message along with the other error messages given. --- evennia/contrib/turnbattle.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index de8cdcad2..78e443c04 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -372,8 +372,6 @@ class CmdAttack(Command): def func(self): "This performs the actual command." "Set the attacker to the caller and the defender to the target." - attacker = self.caller - defender = self.caller.search(self.args) 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)") @@ -386,6 +384,9 @@ class CmdAttack(Command): 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 From 822a17ba63f05d9ec3c2fb667de93f1c1e95616f Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 7 Apr 2017 10:52:58 -0700 Subject: [PATCH 018/133] Added turnbattle command tests Added unit tests for the combat commands in the new 'turnbattle.py' contrib module. --- evennia/contrib/tests.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 62f71281b..18f8817c0 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -755,4 +755,15 @@ class TestTutorialWorldRooms(CommandTest): def test_outroroom(self): create_object(tutrooms.OutroRoom, key="outroroom") +# test turnbattle +from evennia.contrib import turnbattle +class TestTurnBattle(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.") From 32dbe4b396a8b861281262963eddce028b7cae57 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 7 Apr 2017 11:56:26 -0700 Subject: [PATCH 019/133] Added kwargs to resolve_attack Lets you pass values for attack and defense rolls, making it easier to test and extend. --- evennia/contrib/turnbattle.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 78e443c04..452b5824b 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -161,7 +161,7 @@ def apply_damage(defender, damage): if defender.db.hp <= 0: defender.db.hp = 0 -def resolve_attack(attacker, defender): +def resolve_attack(attacker, defender, attack_value=None, defense_value=None): """ Resolves an attack and outputs the result. @@ -175,9 +175,11 @@ def resolve_attack(attacker, defender): so that they are easier to expand upon. """ # Get an attack roll from the attacker. - attack_value = get_attack(attacker, defender) + if not attack_value: + attack_value = get_attack(attacker, defender) # Get a defense value from the defender. - defense_value = get_defense(attacker, 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)) From 96ccfde9ded3bc33dae952475590ac155b8d66e6 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 7 Apr 2017 11:59:02 -0700 Subject: [PATCH 020/133] Added tests for combat functions All the tests are in for the combat functions! I think I just have to test the turn handler script's methods, and then I'll have full coverage. --- evennia/contrib/tests.py | 54 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 18f8817c0..8d5698d65 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -757,8 +757,9 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle from evennia.contrib import turnbattle +from evennia.objects.objects import DefaultRoom -class TestTurnBattle(CommandTest): +class TestTurnBattleCmd(CommandTest): # Test combat commands def test_turnbattlecmd(self): @@ -767,3 +768,54 @@ class TestTurnBattle(CommandTest): 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") + From 6f565be81e9c28e4ddfd9fb2e1ea95f58739ce74 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 7 Apr 2017 12:24:12 -0700 Subject: [PATCH 021/133] Added turn handler script methods to tests I added all of the turn handler script's methods to the unit tests (excluding at_creation, at_repeat, and at_stop) - so I think with the exception of those, my module is now fully covered by unit tests! --- evennia/contrib/tests.py | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 8d5698d65..0c77a2bca 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -799,7 +799,7 @@ class TestTurnBattleFunc(EvenniaTest): turnbattle.resolve_attack(attacker, defender, attack_value=20, defense_value=10) self.assertTrue(defender.db.hp < 40) # Combat cleanup - attacker.db.combat_attribute = True + attacker.db.Combat_attribute = True turnbattle.combat_cleanup(attacker) self.assertFalse(attacker.db.combat_attribute) # Is in combat @@ -814,8 +814,34 @@ class TestTurnBattleFunc(EvenniaTest): # Test is turn self.assertTrue(turnbattle.is_turn(attacker)) # Spend actions - attacker.db.combat_ActionsLeft = 1 + 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") - + 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]) From 005923ee72bb2df5923a6f172d7202a5f4a3f7c0 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 7 Apr 2017 14:22:16 -0700 Subject: [PATCH 022/133] Stop the turn handler script at the end of tests Put in a line of code to stop the turn handler script when the tests are completed, as I think the script lingering and attempting to iterate causes problems with the Travis build. --- evennia/contrib/tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 0c77a2bca..5ab925d8d 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -845,3 +845,5 @@ class TestTurnBattleFunc(EvenniaTest): 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() From 59f491eab44e3407d654a4568e2a95c70bd15a02 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 8 Apr 2017 19:28:29 +0200 Subject: [PATCH 023/133] Some minor adjustments for pep8. --- evennia/contrib/turnbattle.py | 361 ++++++++++++++++++---------------- 1 file changed, 192 insertions(+), 169 deletions(-) diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py index 452b5824b..692656bc3 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle.py @@ -25,7 +25,7 @@ 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 @@ -51,48 +51,51 @@ 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) - + 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. @@ -100,75 +103,79 @@ def get_attack(attacker, defender): # 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. + 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 @@ -184,18 +191,19 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None): 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. + 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) + 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 @@ -204,13 +212,14 @@ def combat_cleanup(character): 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! - + 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 @@ -220,11 +229,12 @@ def is_in_combat(character): 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 @@ -236,29 +246,30 @@ def is_turn(character): 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 + 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. + 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.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions + character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn. + """ ---------------------------------------------------------------------------- @@ -266,26 +277,28 @@ 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 + 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 @@ -305,24 +318,27 @@ class BattleCharacter(DefaultCharacter): # 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. + 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. @@ -336,82 +352,85 @@ class CmdFight(Command): """ here = self.caller.location fighters = [] - - if not self.caller.db.hp: # If you don't have any hp + + 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 + 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 + 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... + 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! + here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight! return here.msg_contents("%s starts a fight!" % self.caller) - here.scripts.add("contrib.turnbattle.TurnHandler") # Add a turn handler script to the room, which starts combat. + # 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. + + 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. + + 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. + + 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. + + if not defender: # No valid target given. return - - if not defender.db.hp: # Target object has no HP left or to begin with + + 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 + + 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. - + 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. """ @@ -424,24 +443,25 @@ class CmdPass(Command): """ This performs the actual command. """ - if not is_in_combat(self.caller): # Can only pass a turn in combat. + 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. + + 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. + 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. @@ -455,48 +475,50 @@ class CmdDisengage(Command): """ This performs the actual command. """ - if not is_in_combat(self.caller): # If you're not in combat + 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 + + 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. + 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 + + 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.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 @@ -511,15 +533,16 @@ class CmdCombatHelp(CmdHelp): """ # 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.|/") + 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 + super(CmdCombatHelp, self).func() # Call the default help command + class BattleCmdSet(default_cmds.CharacterCmdSet): """ @@ -537,13 +560,15 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): 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. @@ -551,90 +576,89 @@ class TurnHandler(DefaultScript): 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.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. + + # Set up the current turn and turn timeout delay. self.db.turn = 0 - self.db.timer = 30 # 30 seconds + 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 - + 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. - + 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. + 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 + 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 - + 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 @@ -642,65 +666,64 @@ class TurnHandler(DefaultScript): separated for movement, by adding "character.db.Combat_MovesLeft = 3" or something similar. """ - character.db.Combat_ActionsLeft = 1 # 1 action per turn. + 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 + if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage disengage_check = False - if disengage_check == True: # All characters have disengaged + 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. + 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 + 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 + 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. + 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. + 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.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. + 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 + 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. """ From 63f4f04d779a9a88aee3008a69319117ca21b0ba Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 8 Apr 2017 19:34:43 +0200 Subject: [PATCH 024/133] Add a short description in the contrib README index. --- evennia/contrib/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 8372b068b..0901e8154 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -45,6 +45,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. From a9bee74b37bb350bdf06e8860d6ff6828cb7b7f7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 11 Apr 2017 08:35:30 +0200 Subject: [PATCH 025/133] Some minor pep8 fixes and refactoring. --- evennia/contrib/menu_login.py | 152 +++++++++++++--------------------- 1 file changed, 59 insertions(+), 93 deletions(-) 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" From e7f0149bf8e083a5af9252164b2f15b9759cd43d Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 9 Apr 2017 23:09:11 -0700 Subject: [PATCH 026/133] Adds new 'clothing' contrib module Adds a new contrib module, providing a system for wearable clothing objects that are appended to one's description. From the module itself: 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. 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. 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. The unit tests have not been put in for this module yet, but I thought I would share it first to get people's thoughts! --- evennia/contrib/clothing.py | 646 ++++++++++++++++++++++++++++++++++++ 1 file changed, 646 insertions(+) create mode 100644 evennia/contrib/clothing.py diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py new file mode 100644 index 000000000..b23961590 --- /dev/null +++ b/evennia/contrib/clothing.py @@ -0,0 +1,646 @@ +""" +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 + +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. 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. 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'same +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 dress : evennia.contrib.clothing.Clothing + @set dress/clothing_type = 'body' +""" + +from evennia import DefaultObject +from evennia import DefaultCharacter +from evennia import default_cmds +from evennia.commands.default.muxcommand import MuxCommand +from evennia.utils import search +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 order in which clothing types appear on the description. Untyped clothing goes last. +CLOTHING_TYPE_ORDER = ['hat','jewelry','top','undershirt','gloves','body','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. +CLOTHING_TYPE_AUTOCOVER = { + 'top':['undershirt'], + 'bottom':['underpants'], + 'body':['undershirt','underpants'], + 'shoes':['socks'] + } +# Types of clothes that can't be used to cover other clothes. +CLOTHING_TYPE_CANT_COVER_WITH = ['jewelry'] + +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 not wearstyle == 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): + """ + 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 == True: + worn_string_list.append(garment.name) + # Otherwise, append the name and the string value of 'worn' + elif thing.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 + +""" +---------------------------------------------------------------------------- +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 == False: + # If 'worn' is True, add to the list + if thing.db.worn == True: + 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 + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + +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()) + +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 dropped. + if to_give.db.worn: + to_give.remove(caller) + obj.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""" + 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) From 17f9a06a689847b9bfdf969811395e7234844ce8 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 9 Apr 2017 23:47:02 -0700 Subject: [PATCH 027/133] Provided example and fixed bugs Added an example of use to the module descriptions, as well as fixed a couple of bugs where the at_get() hook wasn't functioning right and clothing with custom wear styles didn't appear correctly. --- evennia/contrib/clothing.py | 42 ++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py index b23961590..8547d4b29 100644 --- a/evennia/contrib/clothing.py +++ b/evennia/contrib/clothing.py @@ -4,14 +4,42 @@ 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. + + Suuperuser 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. 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. The system as-is is fairly freeform - you +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. @@ -134,7 +162,7 @@ class Clothing(DefaultObject): if not quiet: wearer.location.msg_contents(remove_message) - def at_get(self): + 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 @@ -176,7 +204,7 @@ class ClothedCharacter(DefaultCharacter): if garment.db.worn == True: worn_string_list.append(garment.name) # Otherwise, append the name and the string value of 'worn' - elif thing.db.worn: + elif garment.db.worn: worn_string_list.append("%s %s" % (garment.name, garment.db.worn)) if desc: string += "%s" % desc @@ -242,7 +270,7 @@ def get_worn_clothes(character, exclude_covered=False): # If uncovered or not excluding covered items if not thing.db.covered_by or exclude_covered == False: # If 'worn' is True, add to the list - if thing.db.worn == True: + 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) From 12ff57d883d1226d15e6b555df37b2c0d1c033b0 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 9 Apr 2017 23:47:50 -0700 Subject: [PATCH 028/133] Typo fix Typoed 'Superuser' as 'Suuperuser'. --- evennia/contrib/clothing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py index 8547d4b29..a6ac2d796 100644 --- a/evennia/contrib/clothing.py +++ b/evennia/contrib/clothing.py @@ -17,7 +17,7 @@ A character's description may look like this: Superuser(#1) This is User #1. - Suuperuser is wearing one nice hat, a thin and delicate necklace, + 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. From 86f42228f3a7fcae592ce03e14a431b16b05294d Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 10 Apr 2017 00:00:01 -0700 Subject: [PATCH 029/133] Typo fix Fixed a weird autocomplete-induced typo. --- evennia/contrib/clothing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py index a6ac2d796..3b76a2d2a 100644 --- a/evennia/contrib/clothing.py +++ b/evennia/contrib/clothing.py @@ -51,7 +51,7 @@ inherit from ClothedCharacter in your game's characters.py file: class Character(ClothedCharacter): -And do the same with the ClothedCharacterCmdSet in your game'same +And do the same with the ClothedCharacterCmdSet in your game's default_cmdsets.py: from evennia.contrib.clothing import ClothedCharacterCmdSet From d0ba768fdb5e79ebafd42bfe56229026fc67add4 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 10 Apr 2017 19:15:50 -0700 Subject: [PATCH 030/133] Change to inventory Inventory command now outputs a single line when inventory is completely empty rather than two empty tables. --- evennia/contrib/clothing.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py index 3b76a2d2a..bfe47cfcd 100644 --- a/evennia/contrib/clothing.py +++ b/evennia/contrib/clothing.py @@ -17,7 +17,7 @@ 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, + Suuperuser 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. @@ -51,7 +51,7 @@ inherit from ClothedCharacter in your game's characters.py file: class Character(ClothedCharacter): -And do the same with the ClothedCharacterCmdSet in your game's +And do the same with the ClothedCharacterCmdSet in your game'same default_cmdsets.py: from evennia.contrib.clothing import ClothedCharacterCmdSet @@ -655,6 +655,10 @@ class CmdInventory(MuxCommand): 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") From 6c041b98a240acc9adcd4b2cd165aed54dc037e7 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 10 Apr 2017 19:17:07 -0700 Subject: [PATCH 031/133] Typo fix Fixed reintroduced typos from last commit. Still not using GitHub properly. --- evennia/contrib/clothing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py index bfe47cfcd..07ae8c6c5 100644 --- a/evennia/contrib/clothing.py +++ b/evennia/contrib/clothing.py @@ -17,7 +17,7 @@ A character's description may look like this: Superuser(#1) This is User #1. - Suuperuser is wearing one nice hat, a thin and delicate necklace, + 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. @@ -51,7 +51,7 @@ inherit from ClothedCharacter in your game's characters.py file: class Character(ClothedCharacter): -And do the same with the ClothedCharacterCmdSet in your game'same +And do the same with the ClothedCharacterCmdSet in your game's default_cmdsets.py: from evennia.contrib.clothing import ClothedCharacterCmdSet From 5cc508393bc1b8856bf9d18eb698fddbd39b7025 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 10 Apr 2017 19:19:03 -0700 Subject: [PATCH 032/133] Added unit tests for clothing module Added in unit tests for the clothing module, including all commands, methods, and functions. --- evennia/contrib/tests.py | 59 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 5ab925d8d..d7ecbaa85 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -452,7 +452,66 @@ 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): + self.player.contents = [] + self.call(clothing.CmdWear(), "", "Usage: wear [wear style]", caller=self.player) + self.call(clothing.CmdRemove(), "", "Could not find ''.", caller=self.player) + self.call(clothing.CmdCover(), "", "Usage: cover [with] ", caller=self.player) + self.call(clothing.CmdUncover(), "", "Usage: uncover ", caller=self.player) + self.call(clothing.CmdDrop(), "", "Drop what?", caller=self.player) + self.call(clothing.CmdGive(), "", "Usage: give = ", caller=self.player) + self.call(clothing.CmdInventory(), "", "You are not carrying or wearing anything.", caller=self.player) + +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 From 8da7f45de8827ab0ac4b6cd86c686d9dc6ad0e17 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 16 Apr 2017 19:10:40 -0700 Subject: [PATCH 033/133] Fixes in accordance with Griatch's suggestions Made a few changes: - Put the helper functions above the typeclasses in the module. - Changed the example command given and changed the default clothing type 'body' to 'fullbody', so that clothing types aren't taken to mean wear locations by default - Added note to CLOTHING_TYPE_AUTOCOVER explaining that only clothes that are already worn get auto-covered - Fixed a traceback issue in CmdGive --- evennia/contrib/clothing.py | 225 ++++++++++++++++++------------------ 1 file changed, 114 insertions(+), 111 deletions(-) diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py index 07ae8c6c5..209621168 100644 --- a/evennia/contrib/clothing.py +++ b/evennia/contrib/clothing.py @@ -61,8 +61,8 @@ default_cmdsets.py: From here, you can use the default builder commands to create clothes with which to test the system: - @create a pretty dress : evennia.contrib.clothing.Clothing - @set dress/clothing_type = 'body' + @create a pretty shirt : evennia.contrib.clothing.Clothing + @set shirt/clothing_type = 'top' """ from evennia import DefaultObject @@ -77,7 +77,7 @@ from evennia.utils import evtable # Maximum character length of 'wear style' strings, or None for unlimited. WEARSTYLE_MAXLENGTH = 50 # The order in which clothing types appear on the description. Untyped clothing goes last. -CLOTHING_TYPE_ORDER = ['hat','jewelry','top','undershirt','gloves','body','bottom','underpants','socks','shoes','accessory'] +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, @@ -88,15 +88,123 @@ CLOTHING_TYPE_LIMIT = { # 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'], - 'body':['undershirt','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 == 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): @@ -214,111 +322,6 @@ class ClothedCharacter(DefaultCharacter): else: string += "|/|/%s is not wearing anything." % self return string - -""" ----------------------------------------------------------------------------- -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 == 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 """ ---------------------------------------------------------------------------- @@ -624,10 +627,10 @@ class CmdGive(MuxCommand): 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 dropped. + # Remove clothes if they're given. if to_give.db.worn: to_give.remove(caller) - obj.move_to(caller.location, quiet=True) + 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) From 011ba18d5bdc16496d5f1671bdb6db497e9ad705 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 16 Apr 2017 19:45:26 -0700 Subject: [PATCH 034/133] Made command tests for clothing more robust I added a lot more test cases to the command tests, ensuring that the system works as intended when given the appropriate arguments (as well as no arguments, as before). --- evennia/contrib/tests.py | 47 +++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index d7ecbaa85..46016be3a 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -460,14 +460,45 @@ from evennia.objects.objects import DefaultRoom class TestClothingCmd(CommandTest): def test_clothingcommands(self): - self.player.contents = [] - self.call(clothing.CmdWear(), "", "Usage: wear [wear style]", caller=self.player) - self.call(clothing.CmdRemove(), "", "Could not find ''.", caller=self.player) - self.call(clothing.CmdCover(), "", "Usage: cover [with] ", caller=self.player) - self.call(clothing.CmdUncover(), "", "Usage: uncover ", caller=self.player) - self.call(clothing.CmdDrop(), "", "Drop what?", caller=self.player) - self.call(clothing.CmdGive(), "", "Usage: give = ", caller=self.player) - self.call(clothing.CmdInventory(), "", "You are not carrying or wearing anything.", caller=self.player) + 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): From 0fb7d13d63f675b3c2c28f355a7387c405b3464d Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 17 Apr 2017 08:43:01 +0200 Subject: [PATCH 035/133] Correct for PEP8 compliance. Add contrib entry to README. --- evennia/contrib/README.md | 2 + evennia/contrib/clothing.py | 220 +++++++++++++++++++----------------- 2 files changed, 116 insertions(+), 106 deletions(-) diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 0901e8154..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. diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py index 209621168..0608dac03 100644 --- a/evennia/contrib/clothing.py +++ b/evennia/contrib/clothing.py @@ -8,7 +8,7 @@ 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 + a pair of regular ol' shoes one nice hat a very pretty dress @@ -16,17 +16,17 @@ 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, + + 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) @@ -50,69 +50,67 @@ 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 search 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 +WEARSTYLE_MAXLENGTH = 50 # The order in which clothing types appear on the description. Untyped clothing goes last. -CLOTHING_TYPE_ORDER = ['hat','jewelry','top','undershirt','gloves','fullbody','bottom','underpants','socks','shoes','accessory'] +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 + '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 +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'] + '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 ----------------------------------------------------------------------------- -""" + +# 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 @@ -133,17 +131,18 @@ def order_clothes_list(clothes_list): 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 @@ -153,24 +152,25 @@ def get_worn_clothes(character, exclude_covered=False): clothes_list = [] for thing in character.contents: # If uncovered or not excluding covered items - if not thing.db.covered_by or exclude_covered == False: + 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 @@ -185,15 +185,16 @@ def clothing_type_count(clothes_list): 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 @@ -205,19 +206,20 @@ def single_type_count(clothes_list, 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. - + 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 @@ -230,7 +232,8 @@ class Clothing(DefaultObject): 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]: + 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 @@ -238,29 +241,29 @@ class Clothing(DefaultObject): return # Echo a message to the room message = "%s puts on %s" % (wearer, self.name) - if not wearstyle == True: + 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 anything is covered by if thing.db.covered_by == self: thing.db.covered_by = False uncovered_list.append(thing.name) @@ -269,7 +272,7 @@ class Clothing(DefaultObject): # 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' @@ -277,7 +280,8 @@ class Clothing(DefaultObject): location changed without getting removed. """ self.db.worn = False - + + class ClothedCharacter(DefaultCharacter): """ Character that displays worn clothing when looked at. You can also @@ -291,7 +295,7 @@ class ClothedCharacter(DefaultCharacter): 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 @@ -309,7 +313,7 @@ class ClothedCharacter(DefaultCharacter): # Append worn, uncovered clothing to the description for garment in clothes_list: # If 'worn' is True, just append the name - if garment.db.worn == True: + 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: @@ -323,37 +327,9 @@ class ClothedCharacter(DefaultCharacter): string += "|/|/%s is not wearing anything." % self return string -""" ----------------------------------------------------------------------------- -COMMANDS START HERE ----------------------------------------------------------------------------- -""" - -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()) - +# COMMANDS START HERE + class CmdWear(MuxCommand): """ Puts on an item of clothing you are holding. @@ -364,10 +340,10 @@ class CmdWear(MuxCommand): 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. + provide will be displayed after the clothing's name. """ key = "wear" @@ -387,12 +363,12 @@ class CmdWear(MuxCommand): 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) @@ -400,7 +376,7 @@ class CmdWear(MuxCommand): 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 @@ -413,14 +389,15 @@ class CmdWear(MuxCommand): 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. @@ -444,13 +421,14 @@ class CmdRemove(MuxCommand): 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. @@ -463,7 +441,7 @@ class CmdCover(MuxCommand): """ This performs the actual command. """ - + if len(self.arglist) < 2: self.caller.msg("Usage: cover [with] ") return @@ -497,17 +475,18 @@ class CmdCover(MuxCommand): 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 + 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 @@ -521,7 +500,7 @@ class CmdUncover(MuxCommand): """ This performs the actual command. """ - + if not self.args: self.caller.msg("Usage: uncover ") return @@ -541,7 +520,8 @@ class CmdUncover(MuxCommand): 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 @@ -572,7 +552,7 @@ class CmdDrop(MuxCommand): 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: @@ -589,7 +569,8 @@ class CmdDrop(MuxCommand): exclude=caller) # Call the object script's at_drop() method. obj.at_drop(caller) - + + class CmdGive(MuxCommand): """ give away something to someone @@ -623,7 +604,7 @@ class CmdGive(MuxCommand): 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. + # 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 @@ -637,7 +618,8 @@ class CmdGive(MuxCommand): 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 @@ -650,7 +632,7 @@ class CmdInventory(MuxCommand): """ # Alternate version of the inventory command which separates # worn and carried items. - + key = "inventory" aliases = ["inv", "i"] locks = "cmd:all()" @@ -661,7 +643,7 @@ class CmdInventory(MuxCommand): 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") @@ -679,3 +661,29 @@ class CmdInventory(MuxCommand): 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()) From bc1f5f0fc9c4f2921c5a9a382a949aa9ec384ee9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 17 Apr 2017 08:52:18 +0200 Subject: [PATCH 036/133] Fix to color normalization end tag in telnet. Resolves #1306. --- evennia/server/portal/telnet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index e9734f14a..d4d1cda25 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -337,7 +337,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) From feed89257ff7429c54e6159ccbf33cbe329d29eb Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 18 Apr 2017 00:45:38 +0200 Subject: [PATCH 037/133] Fix indexing bug with using an accidental :: prefix in EvEditor, fix function of the ::: editor command. --- evennia/utils/eveditor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/utils/eveditor.py b/evennia/utils/eveditor.py index ec0a825b1..d04582c32 100644 --- a/evennia/utils/eveditor.py +++ b/evennia/utils/eveditor.py @@ -275,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) @@ -426,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": From f7955340a7c031fb43cb23d3cf530c9ee0fb7926 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 18 Apr 2017 18:29:11 +0200 Subject: [PATCH 038/133] Remove ability to teleport puppeted objects to a None location, as per #1304. --- evennia/commands/default/building.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 5a19f8619..02b09c015 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2241,7 +2241,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. """ @@ -2266,19 +2266,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 From 154799f6e074a19e864d5c66ba27fc26c977924b Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 17 Apr 2017 04:22:56 -0400 Subject: [PATCH 039/133] Fix to color normalization end tag in ssh. Initial fix in #1243 does not account for empty string. Addresses a potential problem similar to IndexError in telnet.py #1306 --- evennia/server/portal/ssh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From ae2c2be8a13d9f196eb1695713d8520b8bb30b9b Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 17 Apr 2017 04:29:51 -0400 Subject: [PATCH 040/133] Fix to color normalization end tag in Telnet. Initial fix in #1243 does not account for empty string. Addresses a potential problem in Telnet prompt similar to IndexError in telnet.py #1306 --- evennia/server/portal/telnet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index d4d1cda25..a579e001e 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -309,7 +309,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) From 7e8e47b3b3e34634a5dd216756177071d82f7663 Mon Sep 17 00:00:00 2001 From: Tehom Date: Thu, 20 Apr 2017 14:14:00 -0400 Subject: [PATCH 041/133] Fix attribute name to resolve recursion error in _recache method. --- evennia/objects/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 4a56df1ea..4d9bb05c2 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -59,7 +59,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"]) From 4327bc48d3f4384de4c5c42164a9c7c933fe7b48 Mon Sep 17 00:00:00 2001 From: CloudKeeper1 Date: Sat, 22 Apr 2017 16:01:32 +1000 Subject: [PATCH 042/133] Extended get_input to accept *args & **kwargs Calling get_input with args and kwargs passes them to the callback function. Does not affect previous use so no changed required to existing code. --- evennia/utils/evmenu.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index a59855716..bd00b7578 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -894,9 +894,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 @@ -930,7 +932,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. @@ -981,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) From 9c37665f00d658321de147b5449e3a81dfd8507c Mon Sep 17 00:00:00 2001 From: CloudKeeper1 Date: Sun, 23 Apr 2017 21:40:37 +1000 Subject: [PATCH 043/133] Updated documentation. Updated documentation explaining use. Also mentioned the result of chaining get_inputs. --- evennia/utils/evmenu.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index bd00b7578..117492e05 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -957,6 +957,13 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs): 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. @@ -975,6 +982,12 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs): may not be easy to get if caller is a player in higher multisession modes), then it is available in the callback through `caller.ndb._getinput._session`. + + Chaining get_input functions will result in the caller + stacking ever more instances of InputCmdSets. Whilst + they will all be cleared on concluding the get_input + chain, EvMenu should be considered for anything beyond + a single question. """ if not callable(callback): From 6a621af4d7439518b780dc1b2b05318f782c3b29 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 23 Apr 2017 14:06:18 +0200 Subject: [PATCH 044/133] Remove spurious whitespace from sources. --- evennia/utils/evmenu.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 117492e05..f0cbff754 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -957,12 +957,12 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs): 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' + *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 + and the callback function must take *args and **kwargs as arguments. Raises: @@ -982,11 +982,11 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs): may not be easy to get if caller is a player in higher multisession modes), then it is available in the callback through `caller.ndb._getinput._session`. - + Chaining get_input functions will result in the caller - stacking ever more instances of InputCmdSets. Whilst - they will all be cleared on concluding the get_input - chain, EvMenu should be considered for anything beyond + 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. """ From d012974650f7223991d1a85db8e30e07172f08cb Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Apr 2017 22:59:59 +0200 Subject: [PATCH 045/133] Fix bug in irc disconnect, trying to pass a reason when portal.disconnect does not support one. --- evennia/server/portal/irc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() From 477d7883c86440b31bcc75e5c24a06ffdb4ca77f Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Apr 2017 23:34:44 +0200 Subject: [PATCH 046/133] Fix so evennia.utils.delay actually returns a deferred as the docstring says. --- evennia/utils/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 79add3205..05ff24f0e 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 From 9b3ca3ef203c32589caf3e56cc19086c5c55d880 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 23 Apr 2017 18:44:16 -0700 Subject: [PATCH 047/133] More thorough explanation of clothing types Added some further explanation of clothing types and their purpose. I felt like it wasn't clear enough that you could change the given clothing types or even elect not to use clothing types at all in your own implementation of the system. --- evennia/contrib/clothing.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py index 0608dac03..7fc85b7c8 100644 --- a/evennia/contrib/clothing.py +++ b/evennia/contrib/clothing.py @@ -76,7 +76,14 @@ from evennia.utils import evtable # Options start here. # Maximum character length of 'wear style' strings, or None for unlimited. WEARSTYLE_MAXLENGTH = 50 -# The order in which clothing types appear on the description. Untyped clothing goes last. + +# 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. From 4d8d26fd2192b8e4e2fdff38f97ec73987bfa802 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 3 May 2017 22:52:08 +0200 Subject: [PATCH 048/133] Fix small typo in settings. Resolves #1316. --- evennia/settings_default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 233e8e42a..c5844205c 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -224,7 +224,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) From 5de1f7b45bbdd7856024fb45841acd5fd55148ee Mon Sep 17 00:00:00 2001 From: Tehom Date: Sat, 6 May 2017 03:21:50 -0400 Subject: [PATCH 049/133] Add dbclass to names of classdict for cache_size function to remove duplicate entries --- evennia/utils/idmapper/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/utils/idmapper/models.py b/evennia/utils/idmapper/models.py index 06e303be1..54013e3dd 100644 --- a/evennia/utils/idmapper/models.py +++ b/evennia/utils/idmapper/models.py @@ -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__()) From 841e0031aebad4b795aefff6a1f2c35a5b06d981 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 7 Feb 2017 18:54:12 -0800 Subject: [PATCH 050/133] Add the first draft of documentation for the event system --- evennia/contrib/events/README.md | 560 +++++++++++++++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100644 evennia/contrib/events/README.md diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md new file mode 100644 index 000000000..3bdd78508 --- /dev/null +++ b/evennia/contrib/events/README.md @@ -0,0 +1,560 @@ +# 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 trusted builders) could have a specific room, exit, character, object or something else behaves 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 two important questions, and answer them for yourself, before deciding to use this system in your game: + +1. Is it worth it? This event system isn't some magical feature that would remove the need for the MUD's development, and empower immortals to create fabulous things without any control. Everything that immortals would be able to do through the event system could be achieved by modifying the source code. Immortals might be familiar with Evennia's design, and could contribute by sending pull requests to your code, for instance. The event system could admittedly earn you time and have immortals (or trusted builders) adding in special features without the need for complex code. You have to consider, however, if it's worth adding this system in your game. A possible risk is that your immortals will try to do everything though this system and your code will not be updated, while there will still be room to enhance it. +2. Is it safe? Having arbitrary Python code running cannot be considered a secure feature. You will have to be extremely careful in deciding who can use this system. By default, immortals can create and edit events (these users have access to the `@py` command, which is potentially as dangerous). Builders will not be able to add or edit events, although you can change this setting, to have builders be able to create events, although their events will require approval by an administrator to be run. You can change permissions (see below for more details on how to do it). You are free to trust or mistrust your builders or other users, just remember that the potential for malign code cannot be restricted. + +## Some basic examples + +Before deciding to install this system, it might be worth understanding its possibilities and basic features. The event system allows to create events that can be fired at specific moments. For instance, checking beforehand if a character has some characteristics before allowing him/her to walk through an exit. You will find some examples here (of course, this is only a set of examples, you could do so much more through this system): + + Edit the event 'can_traverse' of a specific exit: + if character.db.health < 30: + character.msg("You are obviously too weak to do that.") + deny() + else: # That's really opional here, but why not? + character.msg("Alrigh, you can go.") + +The `deny()` function denies character from moving and so, after the message has been sent, the action is cancelled (he/she doesn't move). The `else:` statement and instructions are, as in standard Python, optional here. + + Edit the event 'eat' of a specific object: + if character.db.race != "orc": + character.msg("This is a nice-tasting apple, as juicy as you'd like.") + else: + character.msg("You bite into the apple... and spit it out! Do people really eat that?!") + character.db.health -= 10 + +This time, we have an event that behaves differently when a character eats an apple... and is an orc, or something else. Notice that the race system will need to be in your game, the event system just provides ways to access your regular Evennia objects and attributes. + + Edit the event 'time' of a specific NPC with the parameter '19:45': + cmd(character, "say Well, it's time to go home, folks!") + unlock(room, "up") + move(character, "up") + lock(room, "up") + +For this example, at 19:45 sharp (MUD time), the NPC leaves. It can be useful for a shopkeeper to just go in his/her room to sleep, and comeback in the morning. + + Edit the event 'describe' of a specific room with the parameter 'light': + if time("5:00 6:00"): + text = "The gray light of dawn slowly spreads over the harbor.") + elif time("6:00 12:00"): + text = "The sun shines brightly on the waters of the small harbor.") + elif time("12:00 18:00"): + text = "Lengthening shadows fall on the water as the sun continues its course.") + elif time("18:00 21:00"): + text = "Sunset shines on the calm waters of this small harbor.") + else: + text = "It's pitch dark, you can hardly see your hands and hear the gently sound of waves.") + +The description of the room could look something like: + + This is a street made out of wooden planks, running along the + harbor that spreads south from here. To the north is a steep + street that leads deeper into the small city. $light + +When the character looks at this description, the `$light` will be replaced by a specific sentence that will change depending on MUD time. You can have different parts of the room description that will update regarding different factors, like the health of the character looking at the description, the weather surrounding this room, the fulfillment of a quest, the presence of a NPC, and so on. This can be used on every object that supports descriptions. + +You will find more examples in this documentation, along with clear indications on how to use this feature in context. + +## Installation + +The event system isn't installed by default. If you want to use it, you first have to install it. This is done through editing your code: + +In your game settings, you should import the event system. All it takes to do is a simple: + + import contrib.events + +You can do this anywhere in your code, but it's more logical to do it in your settings file (`server/conf/settings.py`). You might prefer to do it in your startup file (`server/conf/at_server_startstop.py`). + +The `@event` command will be added to your character command set. You can, as usual, decide to customize the command and replace the older version in your command set. + +## Basic usage + +The event system relies, to a great extent, on its `@event` command. By default, immortals will be the only ones to have access to this command, for obvious security reasons. You can customize it to be opened to wizards, with or without validation. A section of this document explains how to change this setting. + +### The `@event` command + +The event system can be used on most Evennia objects, mostly typeclassed objects (rooms, exits, characters, objects, and the ones you want to add to your game, players don't use this system however). The first argument of the `@event` command is the name of the object you want to edit. + +#### Examining events + +Let's say we are in a room with two exist, north and south. You could see what events are currently linked with the `north` exit by entering: + + @event north + +The object to display or edit is searched in the room, by default, which makes editing rather easy. However, you can also provide its DBREF (a number) after a `#` sign, like this: + + @event #1 + +(In most settings, this will show the events linked with the character 1, the superuser.) + +The `#DBREF` syntax allows you to edit objects from a distance, without having to move into the rom where these objects are present. + +By default, if you try this command on an object that doesn't have any event, it should display something like: + + No event has been defined in TYPE DISPLAY_NAME. + +If there are events linked to this object, you will see them in a table (with the event and the number of line). + +#### Creating a new event + +The `/add` switch should be used to add an event. It takes two arguments beyond the object's name/DBREF: + +1. After an = sign, the event to be edited (if not supplied, will display the list of possible events). +2. The parameters (optional). + +We'll see events with parameters later. For now, let's create an event 'can_traverse' connected to the exit 'north' in this room: + + @event/add north = can_traverse + +This will create a new event connected to this exit. It will be fired before a character traverses this exit. It is possible to prevent the character from moving at this point. + +This command should open a line-editor. This editor is described in greater details in another section. For now, you can write instructions as normal: + + 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 event. + +Then try to walk through this exit. Do it with another character if possible, too, to see the difference. + +If you are immortal, by default, this command should automatically connect the event, and activate it. You can set some wizards/builders to be allowed to add events, but to validate individual events each time, to make sure they are using the system with no harmful intents. These events will be created, but they will not be connected before you validate them. + +#### Editing an event + +You can use the `/edit` switch to the `@event` command to edit an event. 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 events are connected at this location. + +You can type `@event/edit = ` to see the events that are linked at this location. If there is only one event, it will be opened in the editor; if more are defined, you will be asked for a number to provide (for instance, `@event/edit north can_traverse 2`). + +Users under validation will be able to edit their own events, but not the events of others. Editing an event that went into validation will disconnect it and have it sent to validation again. + +#### Removing an event + +The command `@event` also provides a `/del` switch to remove an event. It takes the same arguments as the `/edit` switch: + +1. The name of the object. +2. The name of the event after an = sign. +3. Optionally a number if more than one event are located there. + +When removed, events are logged, so an administrator can retrieve its content, assuming the `/del` was an error and the administrator has access to log files (which is often the case). + +### The event editor + +When adding or editing an event, the event editor should open. It is basically the same as [EvEditor](https://github.com/evennia/evennia/wiki/EvEditor), which so ressemble VI, but it adds a couple of options to handle indentation. + +Python is a programming language that needs correct indentation. It is not an aesthetic concern, but a requirement to differentiate between blocks. The event editor will try to guess the right level of indentation to make your life easier, but it will not be perfect. + +- If you enter an instruction beginning by `if`, `elif`, or `else`, the editor will automatically increase the level of indentation of the next line. +- If the instruction is an `elif` or `else`, the editor will look for the opening block of `if` and match indentation. +- Blocks `while`, `for`, `try`, `except`, 'finally' obey the same rules. + +There are still some cases when you must tell the editor to reduce or increase indentation. The usual use cases are: + +1. When you close a condition or loop, the editor will not be able to tell. +2. When you want to keep the instruction on several lines, the editor will not bother with indentation. + +In both cases, you should use the `:+` command (increase indentation by one level) and `:-` (decrease indentation by one level). Indentation is always shown when you add a new line in your event. + +In all the cases shown above, you don't need to enter your indentation manually. Just change the indentation whenever needed, don't bother to write spaces or tabulations at the beginning of your line. For instance, you could enter the following lines in your client: + +``` +if character.id == 1: +character.msg("You're the big boss.") +else: +character.msg("I don't know who you are.") +:- +character.msg("This is not inside of the condition.") +``` + +This will produce the following code: + +``` +if character.id == 1: + character.msg("You're the big boss.") +else: + character.msg("I don't know who you are.") + +character.msg("This is not inside of the condition.") +``` + +You can also disable the automatic-indentation mode. Just enter the command `:=`. In this mode, you will have to manually type in the spaces or tabulations, the editor will not indent anything without you asking to do it. This mode can be useful if you copy/paste some code and want to keep the original indentation. + +### Editing permissions + +This contrib is installed with default permissions. They define who can edit events without validation, and who can edit events but needs validation. Validation is a process in which an administrator (or somebody trusted as such) will check the events produced by others and will accept or reject them. If accepted, the events are connected, otherwise they are never run. + +By default, events can only be created by immortals. They don't need to be validated by anyone, after all, immortals also have access to the `@py` command, so they are probably trusted to use it wisely and not to run dangerous code on your server. + +That's the default configuration: no one except the immortals can edit events, and immortals don't need validation. It can easily be changed, either through settings or dynamically by changing permissions of users. + +#### Permissions in settings + +The events contrib adds three permissions in the settings. You can override them by importing 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 group that can edit events, but will need approval. If you set this to "wizards", for instance, users with the permission "wizards" will be able to edit events. These events will not be connected, though, and will need to be checked and approved by an administrator. This setting can contain `None`, meaning that no group is allowed to edit events with validation. +- `EVENTS_WITHOUT_VALIDATION`: this setting defines a permission allowing editing of events without needing validation. By default, this setting is set to "immortals". It means that immortals can edit events, and they will be connected when they leave the editor, without needing approval. +- `EVENTS_VALIDATING`: this last setting defines who can validate events. By default, this is set to "immortals", meaning only immortals can see events needing validation, accept or reject them. + +You can override all these settings in your `server/conf/settings.py` file. For instance: + +``` +from evennia.contrib.events import * + +# ... other settings ... + +# Event settings +EVENTS_WITH_VALIDATION = "wizards" +EVENTS_WITHOUT_VALIDATION = "immortals" +EVENTS_VALIDATING = "immortals" +``` + +This set of settings means that: + +1. Wizards can edit events, but they will need to be individually approved before they are connected. Wizards will be able to add whatever they want, but before their code runs, it will have to be checked and approved by an immortal. +2. Immortals can edit events, their work doesn't need to be approved. It is automatically accepted and connected. +3. Immortals can also see events that need approval (these produced by wizards) and accept or reject them. Whenever accepted, the event is connected and will fire without constraint whenever it has to. + +If you have an active staff of immortals, or are yourself sufficiently active on your project and have some contributors, you might decide to grant the privilege to write events **with** validation to builders, for instance (wizards, as the above permission, will automatically be included). It is recommended not to give contributors the right to edit events without validation unless you know, for a fact, that you can trust them. Remember, events have the potential to do many things... including freeze or crash your server... and potentially worse. + +#### Permissions on individual users + +Sometimes, you have learned to know a contributor and wish to give him or her more privilege without upgrading him/her to a new group. For instance, there's a wizard that you have known for years: you don't know him/her well enough to promote him/her as an immortal, but you are sure he/she won't use the event system with harmful intents. You can give permissions to individual players through the `@perm` command, not altering their group (and then, not giving them extra commands), but allowing them to create events without validation. There are two permissions you can give to individual users: + +- `events_without_validation`: this would give this user the rights to edit events but not require validation before they are connected. If you do this on an individual basis, keep in mind the power granted to this user and carefully consider the potential impacts on your game or machine. +- `events_validating`: this permission allows this user to run validation checks on events needing to be validated. In practice, you shouldn't have to use this last permission, if you trust a user enough to run that path, perhaps he/she could be trusted with immortal permissions. + +For instance, to give the right to edit events 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 `@event` 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 commands (with different switches). + +## Using events + +The following sub-sections describe how to use events for various tasks, from the most simple to the most complex. + +### Standard Python code in events + +This might sound superfluous, considering the previous explanations, but remember you can use standard Python code in your events. Everything that you could do in the source code itself, like changing attributes or aliases, creating or removing objects, can be done through this system. What you will see in the following sub-sections doesn't rely on a new syntax of Python: they add functions and some features, at the best. Events aren't written in softcode, and their syntax might, at first glance, be a bit unfriendly to a user without any programming skills. However, he or she will probably grasp the basic concept very quickly, and will be able to move beyond simple events in his or her own time. + +### The helper functions + +In order to make development a little easier, the event system provides helper functions to be used in events themselves. You don't have to use them, they are just shortcuts. + +The `deny()` function is such a helper. It allows to interrupt the event and the action that called it. In the `can_*` events, it can be used to prevent the action from happening. For instance, in `can_traverse` on exits, it can prevent the user from moving in that direction. One could have a `can_eat` event set on food that would prevent this player to eat this food. Or a `can_say` event in a room that would prevent the player from saying something here. + +Behind the scene, the `deny()` function raises an exception that is being intercepted by the handler of events. Calling this function in events that cannot be stopped may result in errors. + +You could easily add other helper functions. This will greatly depend on the objects you have defined in your game, and how often specific features have to be used by event users. You will find a list of helper functions, their syntax and examples, in the documentation on events. + +### Events with parameters + +Some events are called without parameter. For instance, when a character traverses through an exit, the exit's "traverse" event is called with no argument. In some cases, you can create events that are triggered in only some conditions. A typical example is the room's "say" event. This event is triggered when somebody says something in the room. The 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 event with the parameter "one": + + @event/add here = say one + +This event will only fire when the user says "one" in this room. + +But what if we want to have an event that would fire if the user says 1 or one? We can provide several parameters, separated by a comma. + + @event/add here = say 1, one + +Or, still more keywords: + + @event/add here = say 1, one, ground + +This time, the user could say "ground" or "one" in the room, and it would fire the event. + +Not all events can take parameters, and these who do have a 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. + +### Memory handling + +One frequent question when dealing with events would be in storing information for a latter use. We could, of course, write in the object's attributes themselves, but it might be messy and conflict with code-related features. Memories are here to store information, temporarily or permanently. Since memory is a feature of the event system, when some information changes, adequate events are also triggered, which allows very advanced information handling. + +Memories use a set of helper function that will be described in more details here: + +- `add_memory`: add an information on an object. +- `del_memory`: remove this memory (now or later). +- `memory`: retrieve the value of the memory stored here. +- `has_memory`: has this object stored this memory? + +#### The basics of memories + +Memories are just pieces of information that will be stored in a dedicated field. Just like attributes, these memories contain a key and a value. The key must be a unique string to identify the place where this memory is stored. + +Memories can be stored on every typeclassed object. A character, a room, an object, even an exit can all have memories. + +Let's take a room for a first example. You have an event, `enter`, that is called when a character enters the room. You could decide to store this character, and retrieve it later. Having rooms know the last character who entered can be useful in specific features. So let's see how to do it: + +``` +@event/add here = enter +add_memory(room, "last_character", character) +``` + +This very short event will store the character who enters the room, in a memory called "last_character". + +So far, you might not see the point of memories. Why not use attributes directly? Here are some answers: + +- Memories are tracked down by their own monitors. It means, whenever a memory is added, updated or deleted, events are fired. You can have specific code that will run in these cases, we'll see why it can be interesting later. +- Memories can have a limited lifetime: a memory can expire at some point (you're going to set that using `del_memory`, we'll see how in the following section). You don't have to do anything at this point. + +These two features make memories pretty powerful. And keep in mind that they won't conflict with attributes, since they aren't stored in the same place. + +#### Expiring memories + +One reason why they are called memories is that they can expire. You can set a memory to be permanently-stored (that's the default), or to expire in some time. For instance, a NPC could remember the characters that have attacked and fled it... but only for awhile. A shopkeeper could remember who has attempted to kill hi/her, but given some days, he/she will forget. + +To create an expiring memory, use the `del_memory` function. By default, it just takes the object and the key of the memory. Using the previous example, we could delete the memory in our room like this: + +``` +del_memory(room, "last_character") +``` + +But it can also take a third parameter: the number of seconds before the memory is deleted. + +``` +del_memory(room, "last_character", 300) +``` + +This time, the memory will not be destroyed before 300 seconds (5 minutes). + +#### Reactive memories + +When memories are modified, specific events are called on the object. They are: + +- `add_memory`: a memory has just been added. +- `change_memory`: the value of the memory has been changed. +- `del_memory`: a memory has just been deleted. + +How useful is it? Consider, for example, an object that we will create, a cup of hot tea. We could so easily make it become colder as time passes. + +``` +@event cup of tea = create +# This event will be called when the cup of tea is created +add_memory(object, "temperature", 100) +# We'll say the cup is 100°F when it's created +del_memory(object, "temperature", 20) +# The memory will be erased in 20 seconds +:wq + +@event cup of tea = del_memory temperature +# Note that this event will only be called when the "temperature" +# memory is deleted. 'value' contains the value of the memory +# being deleted. +value -= 5 +# Remove 5 degrees +add_memory(object, "temperature", value) +# Add the memory again +if value > 5: + # If the temperature is above 5, continues to decrease. + del_memory(object, "temperature", 20) + +# Why not change the name according to the temperature? +if value > 70: + object.key = "a hot cup of tea" +elif value > 60: + object.key = "a reasonably-warm cup of tea" +elif value > 40: + object.key = "a cup of tea a bit too cool" +else: + object.key = "a cup of tea positevely cold" +:wq +``` + +Let's take these two events in the order they fire: + +1. First, you create a cup of tea. The memory "temperature" is set to 100. +2. 20 seconds later, the memory is deleted automatically, and the `del_memory` event is called. In this event, we remove 5 degrees from the value and write the memory "temperature" again. So 20 seconds after the cup has been created, the memory "temperature" will be 95. 20 seconds later, 90. 20 seconds later, 85... and so on. +3. The name of the object will vary depending on temperature. +4. When the temperature reaches 5, it stops dropping. It's already rather cold, isn't it? + +One more time, events like this one can be very powerful and come in very useful. You will have to decide, however, if you don't want to create drinks with a "dropping temperature" automatically. Events are great for creating individual features, but code is better to create features used by many. + +### Time-related events + +Events are usually linked to commands. As we saw before with the `del_memory` event, 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 of other events! + +There is a specific event, on all objects, that can trigger at a specific time. It's an event with a mandatory argument, 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): + +``` +@event here = time 12:00 +# This will be called every MUD day at 12:00 PM +room.msg_content("It's noon, time to have lunch!") +``` + +When you save the event, assuming it is auto-validated, at noon every MUD day, this event will fire. You can use this event on every kind of typeclassed object, to have a specific action done every MUD day at the same time. + +### Chained events + +Events can call other events, either now or a bit later. It is potentially very powerful. + +To use chained events, just use the `call` helper function. 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. Creating exits (opening its doors), waiting a bit, closing them, rolling around and stopping at a different station. That's quite a complex set of events, as it is, but let's only look at the part that opens and closes the doors: + +``` +@event here = time 10:00 +# At 10:00 AM, the subway arrives in the room of ID 22 +station = room(id=22) +# Open the door +create_exit("east", room, station) +# Rename the exits +rename_exit(room, "east", "doors", aliases="platform") +rename_exit(station, "west", "doors", aliases="subway") +room.msg_content("The doors open and wind gushes in the subway") +station.msg_content("The doors of the subway open with a dull clank.") +# Set the doors to close in 20 seconds +call(room, "chain_1", 20) +``` + +This event will: + +1. Be called at 10:00 AM (specify 22:00 to say 10:00 PM). +2. Create an exit between the subway (room) and the station (room of ID 22). +3. Renames the exits (it's prettier, let's admit it). +4. Display a message both in the subway and on the platform. +5. Call the event "chain_1" to execute in 20 seconds. + +And now, what should we have in "chain_1"? + +``` +@event here = chain_1 +# Close the doors +del_exit(room, "doors") +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 scene, the `call` function freezes all variables ("room" and "station" in our example), so you don't need to define them afterward. + +A word of caution on events that call chained events: it isn't impossible for an event 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()`, 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. + +### Events in descriptions + +Events can also be used to add dynamic elements into descriptions. The way to use them is to have a single word in the description preceded by a $ sign. The event system will attempt to retrieve the event called "describe" with the mandatory argument of the word that follows the $ sign. + +For instance, if you have the description: + + This is a plain that looks $color. + +The $color indicates to the event system that this is a dynamic portion of the description. The event system will look for this object's "describe" event that has the parameter "color". It will call it and expect it to create a "text" variable that will replace the $color: + +``` +@event here = describe color +if time("22:00 5:00"): + text = "quite indistinct in the dark" +else: + text = "a bit sinister, even in broad daylight" +``` + +When you'll look at this description, if it's between 10 PM and 5 AM, you should see: + + This is a plain that looks quite indistinct in the dark. + +If not, it should display: + + This is a plain that looks a bit sinister, even in broad daylight. + +You can have several dynamic indicators in your description, as long as you have the matching event that defines the "text" variable. + +#### Hooks in description + +To alter descriptions, the event system relies on some hooks that can be overridden. In practice, it is not unlikely that you would have already overriden these hooks and wonder why your descriptions are still static. You can use the method `get_description` in your object to retrieve the `db.desc` attribute with dynamic parts being replaced by the event system. + +In your overridden hooks, instead of using something like: + + desc = self.db.desc + +Use: + + desc = self.get_description() + +That should effectively and effortlessly resolve the issue. + +## Extending events + +This section is dedicated to game developers more than users of the event system. It will explain how to add new helper functions and events. You can skip this section if these topics don't interest you, and see how you can debug your events in the next section. + +### Adding new helper functions + +Helper functions, like `deny()` or `create_exit`, are defined in `contrib/events/helpers.py`. You can add your own helpers by creating a file named `helpers.py` in your `world` directory. The functions defined in this file will be added as helpers. Note that the docstring of each function will be used to generate automatic help. + +You can also decide to create your helper functions in another location, or even in several locations. To do so, edit the `EVENTS_HELPERS_LOCATIONS` 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: + +``` +EVENTS_HELPERS_LOCATIONS = [ + "word/events/helpers", +] +``` + +A helper function is really a Python function. Its docstring should be sufficiently elaborate, so the automatically-generated help of your helpers would prove as usable as the default helpers. + +### Adding new typeclasses + +This section will need to be described more in details, on how to add new typeclasses and, most importantly, how to define their events and how to call them. + +## Debugging events + +In a perfect world, there wouldn't be any bug, any need for debugging. Such, as you know, isn't the case. Sometimes, we need to debug events, in order to understand why it doesn't act as we thought. + +### Examining an event's execution + +Describe the debug mode of individual objects. + +### Errors in events + +There are a lot of ways to make mistakes while writing events. Once you begin, you might encounter syntax errors very often, but leave them behind as you gain in confidence. However, there are still so many ways to trigger errors: passing the wrong arguments to a helper function is only one of many possible examples. + +When an event encounters an error, it stops abruptly and sends the error on a special channel, named "everror", on which you can connect or disconnect should the amount of information be overhwelming. These error messages will contain: + +- The name and ID of the object that encountered the error. +- The name of the event, with possible parameters, that crashed. +- The short error messages (it might not be that short at times). + +The error will also be logged, so an administrator can still access it more completely, seeing the full traceback, which can help to understand the error sometimes. + +### Disabling all events at once + +Last resort, when events 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. One way to do this will be to uninstall the event system, and you can simply comment the line that imports it in your settings. However, if you have imported the system in different files, that might be a bit annoying. You can disable the event system in your `server/conf/at_server_startstop.py` file, in your `at_server_start()` function. + +``` +from contrib.events.controls import disable_events + +def at_server_start(): + """ + This is called every time the server starts up, regardless of + how it was shut down. + """ + disable_events() +``` + +One advantage of this solution is that you will still have access to the `@event` command. Actually, all features of the event system will be available... except no event will fire. This includes description events, time-related events, chained events and normal events. You can then look at the list of events that were modified recently, it might give you an idea of which one is causing all this fuss. From 5e242589d9291f88e2e17b0bc41fa3c4285e5d66 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sat, 4 Mar 2017 16:24:45 -0800 Subject: [PATCH 051/133] Rewrite the README in a development-oriented documentation --- evennia/contrib/events/README.md | 606 +++++++------------------------ 1 file changed, 141 insertions(+), 465 deletions(-) diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md index 3bdd78508..40c98182b 100644 --- a/evennia/contrib/events/README.md +++ b/evennia/contrib/events/README.md @@ -8,189 +8,29 @@ This contrib adds the system of events in Evennia, allowing immortals (or other 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 two important questions, and answer them for yourself, before deciding to use this system in your game: -1. Is it worth it? This event system isn't some magical feature that would remove the need for the MUD's development, and empower immortals to create fabulous things without any control. Everything that immortals would be able to do through the event system could be achieved by modifying the source code. Immortals might be familiar with Evennia's design, and could contribute by sending pull requests to your code, for instance. The event system could admittedly earn you time and have immortals (or trusted builders) adding in special features without the need for complex code. You have to consider, however, if it's worth adding this system in your game. A possible risk is that your immortals will try to do everything though this system and your code will not be updated, while there will still be room to enhance it. -2. Is it safe? Having arbitrary Python code running cannot be considered a secure feature. You will have to be extremely careful in deciding who can use this system. By default, immortals can create and edit events (these users have access to the `@py` command, which is potentially as dangerous). Builders will not be able to add or edit events, although you can change this setting, to have builders be able to create events, although their events will require approval by an administrator to be run. You can change permissions (see below for more details on how to do it). You are free to trust or mistrust your builders or other users, just remember that the potential for malign code cannot be restricted. - -## Some basic examples - -Before deciding to install this system, it might be worth understanding its possibilities and basic features. The event system allows to create events that can be fired at specific moments. For instance, checking beforehand if a character has some characteristics before allowing him/her to walk through an exit. You will find some examples here (of course, this is only a set of examples, you could do so much more through this system): - - Edit the event 'can_traverse' of a specific exit: - if character.db.health < 30: - character.msg("You are obviously too weak to do that.") - deny() - else: # That's really opional here, but why not? - character.msg("Alrigh, you can go.") - -The `deny()` function denies character from moving and so, after the message has been sent, the action is cancelled (he/she doesn't move). The `else:` statement and instructions are, as in standard Python, optional here. - - Edit the event 'eat' of a specific object: - if character.db.race != "orc": - character.msg("This is a nice-tasting apple, as juicy as you'd like.") - else: - character.msg("You bite into the apple... and spit it out! Do people really eat that?!") - character.db.health -= 10 - -This time, we have an event that behaves differently when a character eats an apple... and is an orc, or something else. Notice that the race system will need to be in your game, the event system just provides ways to access your regular Evennia objects and attributes. - - Edit the event 'time' of a specific NPC with the parameter '19:45': - cmd(character, "say Well, it's time to go home, folks!") - unlock(room, "up") - move(character, "up") - lock(room, "up") - -For this example, at 19:45 sharp (MUD time), the NPC leaves. It can be useful for a shopkeeper to just go in his/her room to sleep, and comeback in the morning. - - Edit the event 'describe' of a specific room with the parameter 'light': - if time("5:00 6:00"): - text = "The gray light of dawn slowly spreads over the harbor.") - elif time("6:00 12:00"): - text = "The sun shines brightly on the waters of the small harbor.") - elif time("12:00 18:00"): - text = "Lengthening shadows fall on the water as the sun continues its course.") - elif time("18:00 21:00"): - text = "Sunset shines on the calm waters of this small harbor.") - else: - text = "It's pitch dark, you can hardly see your hands and hear the gently sound of waves.") - -The description of the room could look something like: - - This is a street made out of wooden planks, running along the - harbor that spreads south from here. To the north is a steep - street that leads deeper into the small city. $light - -When the character looks at this description, the `$light` will be replaced by a specific sentence that will change depending on MUD time. You can have different parts of the room description that will update regarding different factors, like the health of the character looking at the description, the weather surrounding this room, the fulfillment of a quest, the presence of a NPC, and so on. This can be used on every object that supports descriptions. - -You will find more examples in this documentation, along with clear indications on how to use this feature in context. +1. Is it worth it? This event system isn't some magical feature that would remove the need for the MU*'s development, and empower immortals to create fabulous things without any control. Everything that immortals would be able to do through the event system could be achieved by modifying the source code. Immortals might be familiar with Evennia's design, and could contribute by sending pull requests to your code, for instance. The event system could admittedly earn you time and have immortals (or trusted builders) adding in special features without the need for complex code. You have to consider, however, if it's worth adding this system in your game. A possible risk is that your immortals will try to do everything though this system and your code will not be updated, while there will still be room to enhance it. +2. Is it safe? Having arbitrary Python code running cannot be considered a secure feature. You will have to be extremely careful in deciding who can use this system. By default, immortals can create and edit events (these users have access to the `@py` command, which is potentially as dangerous). Builders will not be able to add or edit events, although you can change this setting, to have builders be able to create events, and set their events to require approval by an administrator. You can change permissions (see below for more details on how to do it). You are free to trust or mistrust your builders or other users, just remember that the potential for malign code cannot be restricted. ## Installation -The event system isn't installed by default. If you want to use it, you first have to install it. This is done through editing your code: +Being in a separate contrib, the event system isn't installed by default. You need to do it manually, following three steps: -In your game settings, you should import the event system. All it takes to do is a simple: +1. Launch the main script: the event system is contained in a general script that holds all data. It has the advantage of saving nothing in your objects, and you can decide to turn it on and off fairly easily. In order to turn events on, you need to activate the script. Once executed, the script will remain, including after server reset or reload: + ```@py ev.create_script("evennia.contrib.events.scripts.EventHandler")``` +2. Set the permissions: the event system uses some custom permissions that you can set to define who is allowed to do what, and to what extent (see below for details). Most of these settings will be stored in your setting file (`server/conf/settings.py`): + - `EVENTS_WITH_VALIDATION`: a group that can edit events, but will need approval (default to `None`). + - `EVENTS_WITHOUT_VALIDATION`: a group with permission to edit events without need of validation (default to `"immortals"`). + - `EVENTS_VALIDATING`: a group that can validate events (default to `"immortals"`). + - `EVENTS_CALENDAR`: type of the calendar to be used (either `None`, `"standard"`, `"custom"` or a custom callback, default to `None`). +3. Adding the `@event` command: finally, you will need to add the `@event` command to your Character CmdSet. As with the two previous steps, this is to be done only once: you can disable the event system without removing the `@event` command (a section will describe how useful it can be in case of errors). - import contrib.events +### Starting the event script -You can do this anywhere in your code, but it's more logical to do it in your settings file (`server/conf/settings.py`). You might prefer to do it in your startup file (`server/conf/at_server_startstop.py`). +To start the event script, you only need a single command, using `@py`. -The `@event` command will be added to your character command set. You can, as usual, decide to customize the command and replace the older version in your command set. + @py ev.create_script("evennia.contrib.events.scripts.EventHandler") -## Basic usage - -The event system relies, to a great extent, on its `@event` command. By default, immortals will be the only ones to have access to this command, for obvious security reasons. You can customize it to be opened to wizards, with or without validation. A section of this document explains how to change this setting. - -### The `@event` command - -The event system can be used on most Evennia objects, mostly typeclassed objects (rooms, exits, characters, objects, and the ones you want to add to your game, players don't use this system however). The first argument of the `@event` command is the name of the object you want to edit. - -#### Examining events - -Let's say we are in a room with two exist, north and south. You could see what events are currently linked with the `north` exit by entering: - - @event north - -The object to display or edit is searched in the room, by default, which makes editing rather easy. However, you can also provide its DBREF (a number) after a `#` sign, like this: - - @event #1 - -(In most settings, this will show the events linked with the character 1, the superuser.) - -The `#DBREF` syntax allows you to edit objects from a distance, without having to move into the rom where these objects are present. - -By default, if you try this command on an object that doesn't have any event, it should display something like: - - No event has been defined in TYPE DISPLAY_NAME. - -If there are events linked to this object, you will see them in a table (with the event and the number of line). - -#### Creating a new event - -The `/add` switch should be used to add an event. It takes two arguments beyond the object's name/DBREF: - -1. After an = sign, the event to be edited (if not supplied, will display the list of possible events). -2. The parameters (optional). - -We'll see events with parameters later. For now, let's create an event 'can_traverse' connected to the exit 'north' in this room: - - @event/add north = can_traverse - -This will create a new event connected to this exit. It will be fired before a character traverses this exit. It is possible to prevent the character from moving at this point. - -This command should open a line-editor. This editor is described in greater details in another section. For now, you can write instructions as normal: - - 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 event. - -Then try to walk through this exit. Do it with another character if possible, too, to see the difference. - -If you are immortal, by default, this command should automatically connect the event, and activate it. You can set some wizards/builders to be allowed to add events, but to validate individual events each time, to make sure they are using the system with no harmful intents. These events will be created, but they will not be connected before you validate them. - -#### Editing an event - -You can use the `/edit` switch to the `@event` command to edit an event. 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 events are connected at this location. - -You can type `@event/edit = ` to see the events that are linked at this location. If there is only one event, it will be opened in the editor; if more are defined, you will be asked for a number to provide (for instance, `@event/edit north can_traverse 2`). - -Users under validation will be able to edit their own events, but not the events of others. Editing an event that went into validation will disconnect it and have it sent to validation again. - -#### Removing an event - -The command `@event` also provides a `/del` switch to remove an event. It takes the same arguments as the `/edit` switch: - -1. The name of the object. -2. The name of the event after an = sign. -3. Optionally a number if more than one event are located there. - -When removed, events are logged, so an administrator can retrieve its content, assuming the `/del` was an error and the administrator has access to log files (which is often the case). - -### The event editor - -When adding or editing an event, the event editor should open. It is basically the same as [EvEditor](https://github.com/evennia/evennia/wiki/EvEditor), which so ressemble VI, but it adds a couple of options to handle indentation. - -Python is a programming language that needs correct indentation. It is not an aesthetic concern, but a requirement to differentiate between blocks. The event editor will try to guess the right level of indentation to make your life easier, but it will not be perfect. - -- If you enter an instruction beginning by `if`, `elif`, or `else`, the editor will automatically increase the level of indentation of the next line. -- If the instruction is an `elif` or `else`, the editor will look for the opening block of `if` and match indentation. -- Blocks `while`, `for`, `try`, `except`, 'finally' obey the same rules. - -There are still some cases when you must tell the editor to reduce or increase indentation. The usual use cases are: - -1. When you close a condition or loop, the editor will not be able to tell. -2. When you want to keep the instruction on several lines, the editor will not bother with indentation. - -In both cases, you should use the `:+` command (increase indentation by one level) and `:-` (decrease indentation by one level). Indentation is always shown when you add a new line in your event. - -In all the cases shown above, you don't need to enter your indentation manually. Just change the indentation whenever needed, don't bother to write spaces or tabulations at the beginning of your line. For instance, you could enter the following lines in your client: - -``` -if character.id == 1: -character.msg("You're the big boss.") -else: -character.msg("I don't know who you are.") -:- -character.msg("This is not inside of the condition.") -``` - -This will produce the following code: - -``` -if character.id == 1: - character.msg("You're the big boss.") -else: - character.msg("I don't know who you are.") - -character.msg("This is not inside of the condition.") -``` - -You can also disable the automatic-indentation mode. Just enter the command `:=`. In this mode, you will have to manually type in the spaces or tabulations, the editor will not indent anything without you asking to do it. This mode can be useful if you copy/paste some code and want to keep the original indentation. +This command will create a global script (that is, a script independent from any object). This script will hold basic configuration, event description and so on. You may access it directly, but you will probably use the custom helper functions (see the section on extending the event system). ### Editing permissions @@ -202,17 +42,15 @@ That's the default configuration: no one except the immortals can edit events, a #### Permissions in settings -The events contrib adds three permissions in the settings. You can override them by importing the settings into your `server/conf/settings.py` file (see below for an example). The settings defined in the events contrib are: +The events contrib adds three 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 group that can edit events, but will need approval. If you set this to "wizards", for instance, users with the permission "wizards" will be able to edit events. These events will not be connected, though, and will need to be checked and approved by an administrator. This setting can contain `None`, meaning that no group is allowed to edit events with validation. -- `EVENTS_WITHOUT_VALIDATION`: this setting defines a permission allowing editing of events without needing validation. By default, this setting is set to "immortals". It means that immortals can edit events, and they will be connected when they leave the editor, without needing approval. -- `EVENTS_VALIDATING`: this last setting defines who can validate events. By default, this is set to "immortals", meaning only immortals can see events needing validation, accept or reject them. +- `EVENTS_WITHOUT_VALIDATION`: this setting defines a permission allowing editing of events without needing validation. By default, this setting is set to `"immortals"`. It means that immortals can edit events, and they will be connected when they leave the editor, without needing approval. +- `EVENTS_VALIDATING`: this last setting defines who can validate events. By default, this is set to `"immortals"`, meaning only immortals can see events needing validation, accept or reject them. You can override all these settings in your `server/conf/settings.py` file. For instance: -``` -from evennia.contrib.events import * - +```python # ... other settings ... # Event settings @@ -229,6 +67,12 @@ This set of settings means that: If you have an active staff of immortals, or are yourself sufficiently active on your project and have some contributors, you might decide to grant the privilege to write events **with** validation to builders, for instance (wizards, as the above permission, will automatically be included). It is recommended not to give contributors the right to edit events without validation unless you know, for a fact, that you can trust them. Remember, events have the potential to do many things... including freeze or crash your server... and potentially worse. +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` contrib to schedule events. +- A special callback to schedule time-related events in a way not supported by the `gametime` utility and the `custom_gametime` contrib (see below). + #### Permissions on individual users Sometimes, you have learned to know a contributor and wish to give him or her more privilege without upgrading him/her to a new group. For instance, there's a wizard that you have known for years: you don't know him/her well enough to promote him/her as an immortal, but you are sure he/she won't use the event system with harmful intents. You can give permissions to individual players through the `@perm` command, not altering their group (and then, not giving them extra commands), but allowing them to create events without validation. There are two permissions you can give to individual users: @@ -246,273 +90,128 @@ To remove this same permission, just use the `/del` switch: The rights to use the `@event` 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 commands (with different switches). -## Using events +### Adding the `@event` command -The following sub-sections describe how to use events for various tasks, from the most simple to the most complex. +You also have to add the `@event` command to your Character CmdSet. In your `commands/default_cmdsets`, you might have something like: -### Standard Python code in events +```python +from evennia import default_cmds +from evennia.contrib.events.commands import CmdEvent -This might sound superfluous, considering the previous explanations, but remember you can use standard Python code in your events. Everything that you could do in the source code itself, like changing attributes or aliases, creating or removing objects, can be done through this system. What you will see in the following sub-sections doesn't rely on a new syntax of Python: they add functions and some features, at the best. Events aren't written in softcode, and their syntax might, at first glance, be a bit unfriendly to a user without any programming skills. However, he or she will probably grasp the basic concept very quickly, and will be able to move beyond simple events in his or her own time. - -### The helper functions - -In order to make development a little easier, the event system provides helper functions to be used in events themselves. You don't have to use them, they are just shortcuts. - -The `deny()` function is such a helper. It allows to interrupt the event and the action that called it. In the `can_*` events, it can be used to prevent the action from happening. For instance, in `can_traverse` on exits, it can prevent the user from moving in that direction. One could have a `can_eat` event set on food that would prevent this player to eat this food. Or a `can_say` event in a room that would prevent the player from saying something here. - -Behind the scene, the `deny()` function raises an exception that is being intercepted by the handler of events. Calling this function in events that cannot be stopped may result in errors. - -You could easily add other helper functions. This will greatly depend on the objects you have defined in your game, and how often specific features have to be used by event users. You will find a list of helper functions, their syntax and examples, in the documentation on events. - -### Events with parameters - -Some events are called without parameter. For instance, when a character traverses through an exit, the exit's "traverse" event is called with no argument. In some cases, you can create events that are triggered in only some conditions. A typical example is the room's "say" event. This event is triggered when somebody says something in the room. The 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 event with the parameter "one": - - @event/add here = say one - -This event will only fire when the user says "one" in this room. - -But what if we want to have an event that would fire if the user says 1 or one? We can provide several parameters, separated by a comma. - - @event/add here = say 1, one - -Or, still more keywords: - - @event/add here = say 1, one, ground - -This time, the user could say "ground" or "one" in the room, and it would fire the event. - -Not all events can take parameters, and these who do have a 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. - -### Memory handling - -One frequent question when dealing with events would be in storing information for a latter use. We could, of course, write in the object's attributes themselves, but it might be messy and conflict with code-related features. Memories are here to store information, temporarily or permanently. Since memory is a feature of the event system, when some information changes, adequate events are also triggered, which allows very advanced information handling. - -Memories use a set of helper function that will be described in more details here: - -- `add_memory`: add an information on an object. -- `del_memory`: remove this memory (now or later). -- `memory`: retrieve the value of the memory stored here. -- `has_memory`: has this object stored this memory? - -#### The basics of memories - -Memories are just pieces of information that will be stored in a dedicated field. Just like attributes, these memories contain a key and a value. The key must be a unique string to identify the place where this memory is stored. - -Memories can be stored on every typeclassed object. A character, a room, an object, even an exit can all have memories. - -Let's take a room for a first example. You have an event, `enter`, that is called when a character enters the room. You could decide to store this character, and retrieve it later. Having rooms know the last character who entered can be useful in specific features. So let's see how to do it: +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(CmdEvent()) ``` -@event/add here = enter -add_memory(room, "last_character", character) -``` - -This very short event will store the character who enters the room, in a memory called "last_character". - -So far, you might not see the point of memories. Why not use attributes directly? Here are some answers: - -- Memories are tracked down by their own monitors. It means, whenever a memory is added, updated or deleted, events are fired. You can have specific code that will run in these cases, we'll see why it can be interesting later. -- Memories can have a limited lifetime: a memory can expire at some point (you're going to set that using `del_memory`, we'll see how in the following section). You don't have to do anything at this point. - -These two features make memories pretty powerful. And keep in mind that they won't conflict with attributes, since they aren't stored in the same place. - -#### Expiring memories - -One reason why they are called memories is that they can expire. You can set a memory to be permanently-stored (that's the default), or to expire in some time. For instance, a NPC could remember the characters that have attacked and fled it... but only for awhile. A shopkeeper could remember who has attempted to kill hi/her, but given some days, he/she will forget. - -To create an expiring memory, use the `del_memory` function. By default, it just takes the object and the key of the memory. Using the previous example, we could delete the memory in our room like this: - -``` -del_memory(room, "last_character") -``` - -But it can also take a third parameter: the number of seconds before the memory is deleted. - -``` -del_memory(room, "last_character", 300) -``` - -This time, the memory will not be destroyed before 300 seconds (5 minutes). - -#### Reactive memories - -When memories are modified, specific events are called on the object. They are: - -- `add_memory`: a memory has just been added. -- `change_memory`: the value of the memory has been changed. -- `del_memory`: a memory has just been deleted. - -How useful is it? Consider, for example, an object that we will create, a cup of hot tea. We could so easily make it become colder as time passes. - -``` -@event cup of tea = create -# This event will be called when the cup of tea is created -add_memory(object, "temperature", 100) -# We'll say the cup is 100°F when it's created -del_memory(object, "temperature", 20) -# The memory will be erased in 20 seconds -:wq - -@event cup of tea = del_memory temperature -# Note that this event will only be called when the "temperature" -# memory is deleted. 'value' contains the value of the memory -# being deleted. -value -= 5 -# Remove 5 degrees -add_memory(object, "temperature", value) -# Add the memory again -if value > 5: - # If the temperature is above 5, continues to decrease. - del_memory(object, "temperature", 20) - -# Why not change the name according to the temperature? -if value > 70: - object.key = "a hot cup of tea" -elif value > 60: - object.key = "a reasonably-warm cup of tea" -elif value > 40: - object.key = "a cup of tea a bit too cool" -else: - object.key = "a cup of tea positevely cold" -:wq -``` - -Let's take these two events in the order they fire: - -1. First, you create a cup of tea. The memory "temperature" is set to 100. -2. 20 seconds later, the memory is deleted automatically, and the `del_memory` event is called. In this event, we remove 5 degrees from the value and write the memory "temperature" again. So 20 seconds after the cup has been created, the memory "temperature" will be 95. 20 seconds later, 90. 20 seconds later, 85... and so on. -3. The name of the object will vary depending on temperature. -4. When the temperature reaches 5, it stops dropping. It's already rather cold, isn't it? - -One more time, events like this one can be very powerful and come in very useful. You will have to decide, however, if you don't want to create drinks with a "dropping temperature" automatically. Events are great for creating individual features, but code is better to create features used by many. - -### Time-related events - -Events are usually linked to commands. As we saw before with the `del_memory` event, 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 of other events! - -There is a specific event, on all objects, that can trigger at a specific time. It's an event with a mandatory argument, 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): - -``` -@event here = time 12:00 -# This will be called every MUD day at 12:00 PM -room.msg_content("It's noon, time to have lunch!") -``` - -When you save the event, assuming it is auto-validated, at noon every MUD day, this event will fire. You can use this event on every kind of typeclassed object, to have a specific action done every MUD day at the same time. - -### Chained events - -Events can call other events, either now or a bit later. It is potentially very powerful. - -To use chained events, just use the `call` helper function. 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. Creating exits (opening its doors), waiting a bit, closing them, rolling around and stopping at a different station. That's quite a complex set of events, as it is, but let's only look at the part that opens and closes the doors: - -``` -@event here = time 10:00 -# At 10:00 AM, the subway arrives in the room of ID 22 -station = room(id=22) -# Open the door -create_exit("east", room, station) -# Rename the exits -rename_exit(room, "east", "doors", aliases="platform") -rename_exit(station, "west", "doors", aliases="subway") -room.msg_content("The doors open and wind gushes in the subway") -station.msg_content("The doors of the subway open with a dull clank.") -# Set the doors to close in 20 seconds -call(room, "chain_1", 20) -``` - -This event will: - -1. Be called at 10:00 AM (specify 22:00 to say 10:00 PM). -2. Create an exit between the subway (room) and the station (room of ID 22). -3. Renames the exits (it's prettier, let's admit it). -4. Display a message both in the subway and on the platform. -5. Call the event "chain_1" to execute in 20 seconds. - -And now, what should we have in "chain_1"? - -``` -@event here = chain_1 -# Close the doors -del_exit(room, "doors") -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 scene, the `call` function freezes all variables ("room" and "station" in our example), so you don't need to define them afterward. - -A word of caution on events that call chained events: it isn't impossible for an event 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()`, 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. - -### Events in descriptions - -Events can also be used to add dynamic elements into descriptions. The way to use them is to have a single word in the description preceded by a $ sign. The event system will attempt to retrieve the event called "describe" with the mandatory argument of the word that follows the $ sign. - -For instance, if you have the description: - - This is a plain that looks $color. - -The $color indicates to the event system that this is a dynamic portion of the description. The event system will look for this object's "describe" event that has the parameter "color". It will call it and expect it to create a "text" variable that will replace the $color: - -``` -@event here = describe color -if time("22:00 5:00"): - text = "quite indistinct in the dark" -else: - text = "a bit sinister, even in broad daylight" -``` - -When you'll look at this description, if it's between 10 PM and 5 AM, you should see: - - This is a plain that looks quite indistinct in the dark. - -If not, it should display: - - This is a plain that looks a bit sinister, even in broad daylight. - -You can have several dynamic indicators in your description, as long as you have the matching event that defines the "text" variable. - -#### Hooks in description - -To alter descriptions, the event system relies on some hooks that can be overridden. In practice, it is not unlikely that you would have already overriden these hooks and wonder why your descriptions are still static. You can use the method `get_description` in your object to retrieve the `db.desc` attribute with dynamic parts being replaced by the event system. - -In your overridden hooks, instead of using something like: - - desc = self.db.desc - -Use: - - desc = self.get_description() - -That should effectively and effortlessly resolve the issue. ## Extending events -This section is dedicated to game developers more than users of the event system. It will explain how to add new helper functions and events. You can skip this section if these topics don't interest you, and see how you can debug your events in the next section. +This section will explain how to add new helper functions and events. + +### Adding new event types + +Default events are great but you may need more events to fit with your purposes. For instance, if you have a `yell` command and would like a `can_yell` event in all your rooms. + +The way to do this is to add, below your class definition, lines to add these events. The `create_event` function should be called. It takes the following arguments: + +- The class to have these events (defined above). +- The name of the event to add (str). +- The list of variables to be present when calling this events (list of str). +- The help text of this event (str). + +Here's an example of adding the `can_yell` event to all your rooms: + +```python +# In typeclasses/rooms.py +from evennia import DefaultRoom +from evennia.contrib.events.extend import create_event + +class Room(DefaultRoom): + """ + Rooms are like any Object, except their location is None + (which is default). They also use basetype_setup() to + add locks so they cannot be puppeted or picked up. + (to change that, use at_object_creation instead) + + See examples/object.py for a list of + properties and methods available on all Objects. + """ + pass + +# Room events +create_event(Room, "can_yell", ["character", "room", "message"], """ + Can the character yell in this room? + This event is called when a character uses the 'yell' command + to yell in this room. This event is called BEFORE the character + yells, and the room can prevent the command by executing + 'deny()'. The 'character' variable contains the character + who wants to yell, the 'room' variable contains the room + in which the character wants to yell, and the 'message' + variable contains the message about to be yelled by the character. +""") +``` + +After this code has been executed, when you type `@event here` to see the events in this room, you will see the `can_yell` event. The first line of the help text is displayed as a short explanation, so you should always try to format your help files that way. + +At this point, the event has been added, but is not being called yet. To call it, you need to edit your `yell` command, and use the `call` function. You will probably end up with something like: + +```python +from evennia import Command +from evennia.contrib.events.helpers import call + +class CmdYell(Command): + + """ + Yell in this room. + + Usage: + yell + + """ + + def func(self): + """Execute the command.""" + character = self.caller + location = character.location + message = self.args + + # Check that the character can yell in this room + if not call(location, "can_yell", character, location, message): + # It has been denied, so stop the command here + return + + # Yell in this room + location.msg_contents("{char} yells: {msg}.", + mapping=dict(char=character, msg=message)) +''' + +Note that the `call` function takes as argument: + +- The object with the event (here, `location`). +- The name of the event to be called (here, `can_yell`). +- The variables as positional arguments, in the same order they were specified in `create_event`. + +The `call` function will return `False` if the event has been interrupted by a `deny()` call. ### Adding new helper functions -Helper functions, like `deny()` or `create_exit`, are defined in `contrib/events/helpers.py`. You can add your own helpers by creating a file named `helpers.py` in your `world` directory. The functions defined in this file will be added as helpers. Note that the docstring of each function will be used to generate automatic help. +Helper functions, like `deny(), are defined in `contrib/events/helpers.py`. You can add your own helpers by creating a file named `helpers.py` in your `world` directory. The functions defined in this file will be added as helpers. Note that the docstring of each function will be used to generate automatic help. You can also decide to create your helper functions in another location, or even in several locations. To do so, edit the `EVENTS_HELPERS_LOCATIONS` 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 EVENTS_HELPERS_LOCATIONS = [ - "word/events/helpers", + "world.events.helpers", ] ``` @@ -520,41 +219,18 @@ A helper function is really a Python function. Its docstring should be sufficie ### Adding new typeclasses -This section will need to be described more in details, on how to add new typeclasses and, most importantly, how to define their events and how to call them. +Adding a new typeclass is not different from extending one, and will obey to the same rules: define the class as you have been accustomed to doing, and create the events with `create_event` under the class definition. -## Debugging events +Note: events obey the inheritance hierarchy: if you define events on the `Room` class, then create a typeclass inheriting from `Room`, the objects of this latter typeclass will have events of both typeclasses. -In a perfect world, there wouldn't be any bug, any need for debugging. Such, as you know, isn't the case. Sometimes, we need to debug events, in order to understand why it doesn't act as we thought. +## Disabling all events at once -### Examining an event's execution +When events 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`): -Describe the debug mode of individual objects. - -### Errors in events - -There are a lot of ways to make mistakes while writing events. Once you begin, you might encounter syntax errors very often, but leave them behind as you gain in confidence. However, there are still so many ways to trigger errors: passing the wrong arguments to a helper function is only one of many possible examples. - -When an event encounters an error, it stops abruptly and sends the error on a special channel, named "everror", on which you can connect or disconnect should the amount of information be overhwelming. These error messages will contain: - -- The name and ID of the object that encountered the error. -- The name of the event, with possible parameters, that crashed. -- The short error messages (it might not be that short at times). - -The error will also be logged, so an administrator can still access it more completely, seeing the full traceback, which can help to understand the error sometimes. - -### Disabling all events at once - -Last resort, when events 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. One way to do this will be to uninstall the event system, and you can simply comment the line that imports it in your settings. However, if you have imported the system in different files, that might be a bit annoying. You can disable the event system in your `server/conf/at_server_startstop.py` file, in your `at_server_start()` function. - -``` -from contrib.events.controls import disable_events - -def at_server_start(): - """ - This is called every time the server starts up, regardless of - how it was shut down. - """ - disable_events() +```python +# Disable all events +EVENTS_DISABLED = True ``` -One advantage of this solution is that you will still have access to the `@event` command. Actually, all features of the event system will be available... except no event will fire. This includes description events, time-related events, chained events and normal events. You can then look at the list of events that were modified recently, it might give you an idea of which one is causing all this fuss. +The event system will still be accessible (you will have access to the `@event` command, to debug), but no event will be called automatically. + From a88e7ad4d965dc7ce0a0644273593634e08656bd Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sat, 4 Mar 2017 16:30:22 -0800 Subject: [PATCH 052/133] Fix a typo in the README file --- evennia/contrib/events/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md index 40c98182b..a4256fd93 100644 --- a/evennia/contrib/events/README.md +++ b/evennia/contrib/events/README.md @@ -193,7 +193,7 @@ class CmdYell(Command): # Yell in this room location.msg_contents("{char} yells: {msg}.", mapping=dict(char=character, msg=message)) -''' +``` Note that the `call` function takes as argument: From 9f581ea7e18415cce569a07f548e6c970b03e79f Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 7 Mar 2017 17:24:44 -0800 Subject: [PATCH 053/133] Add user documentation for the event system --- evennia/contrib/events/USERDOC.md | 315 +++++++++++++++++++++++++++++ evennia/contrib/events/__init__.py | 0 evennia/contrib/events/scripts.py | 14 ++ 3 files changed, 329 insertions(+) create mode 100644 evennia/contrib/events/USERDOC.md create mode 100644 evennia/contrib/events/__init__.py create mode 100644 evennia/contrib/events/scripts.py diff --git a/evennia/contrib/events/USERDOC.md b/evennia/contrib/events/USERDOC.md new file mode 100644 index 000000000..a89e30097 --- /dev/null +++ b/evennia/contrib/events/USERDOC.md @@ -0,0 +1,315 @@ +# Evennia's event system, user documentation + +Evennia's event system allows to add dynamic features in your world without editing the source code. These features are placed on individual objects, and can offer opportunities to customize a few objects without customizing all of them. Usages can range from: + +- Adding dialogues to some characters (a NPC greeting player-characters). +- Adding some custom actions at specific in-game moments (a shop-keeper going home at 8 PM and coming back to the shop in the morning). +- Build complex quests (a set of actions with conditions required to obtain some reward or advantage). +- Deny a command from executing based on some conditions (prevent a character from going in some room without completing some quest). +- Have some objects react in specific ways when some action occurs (a character enters the room, a character says something). + +In short, the event system allows what other engines would implement through soft code or "scripting". The event system in Evennia doesn't rely on a homemade language, however, but on Python, and therefore allows almost everything possible through modifications to the source code. It's not necessary to know Evennia to use the event system, although knowing some basis of Evennia (the system of typeclasses and attributes, for instance) will not hurt. + +## Some basic examples + +Before beginning to use this system, it might be worth understanding its possibilities and basic features. The event system allows to create events that can be fired at specific moments. For instance, checking beforehand if a character has some characteristics before allowing him/her to walk through an exit. You will find some examples here (of course, this is only a list of examples, you could do so much more through this system): + + Edit the event 'can_traverse' of a specific exit: + if character.db.health < 30: + character.msg("You are obviously too weak to do that.") + deny() + else: # That's really opional here, but why not? + character.msg("Alrigh, you can go.") + +The `deny()` function denies characters from moving and so, after the message has been sent, the action is cancelled (he/she doesn't move). The `else:` statement and instructions are, as in standard Python, optional here. + + Edit the event 'eat' of a specific object: + if character.db.race != "goblin": + character.msg("This is a nice-tasting apple, as juicy as you'd like.") + else: + character.msg("You bite into the apple... and spit it out! Do people really eat that?!") + character.db.health -= 10 + +This time, we have an event that behaves differently when a character eats an apple... and is a goblin, or something else. Notice that the race system will need to be in your game, the event system just provides ways to access your regular Evennia objects and attributes. + + Edit the event 'time' of a specific NPC with the parameter '19:45': + cmd(character, "say Well, it's time to go home, folks!") + exit = character.location.search("up") + + exit.db.lock = False + exit.db.closed = False + move(character, "up") + exit.db.closed = True + exit.db.lock = True + +For this example, at 19:45 sharp (game time), the NPC leaves. It can be useful for a shop-keeper to just go in his/her room to sleep, and comeback in the morning. + +You will find more examples in this documentation, along with clear indications on how to use this feature in context. + +## Basic usage + +The event system relies, to a great extent, on its `@event` command. By default, immortals will be the only ones to have access to this command, for obvious security reasons. + +### The `@event` command + +The event system can be used on most Evennia objects, mostly typeclassed objects (rooms, exits, characters, objects, and the ones you want to add to your game, players don't use this system however). The first argument of the `@event` command is the name of the object you want to edit. + +#### Examining events + +Let's say we are in a room with two exist, north and south. You could see what events are currently linked with the `north` exit by entering: + + @event north + +The object to display or edit is searched in the room, by default, which makes editing rather easy. However, you can also provide its DBREF (a number) after a `#` sign, like this: + + @event #1 + +(In most settings, this will show the events linked with the character 1, the superuser.) + +By default, if you try this command on an object that doesn't have any event, it should display something like: + + No event has been defined in TYPE DISPLAY_NAME. + +If there are events linked to this object, you will see them in a table (with the event and the number of line). + +#### Creating a new event + +The `/add` switch should be used to add an event. It takes two arguments beyond the object's name/DBREF: + +1. After an = sign, the event to be edited (if not supplied, will display the list of possible events). +2. The parameters (optional). + +We'll see events with parameters later. For now, let's create an event 'can_traverse' connected to the exit 'north' in this room: + + @event/add north = can_traverse + +This will create a new event connected to this exit. It will be fired before a character traverses this exit. It is possible to prevent the character from moving at this point. + +This command should open a line-editor. This editor is described in greater details in another section. For now, you can write instructions as normal: + + 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 event. + +Then try to walk through this exit. Do it with another character if possible, too, to see the difference. + +#### Editing an event + +You can use the `/edit` switch to the `@event` command to edit an event. 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 events are connected at this location. + +You can type `@event/edit = ` to see the events that are linked at this location. If there is only one event, it will be opened in the editor; if more are defined, you will be asked for a number to provide (for instance, `@event/edit north = can_traverse 2`). + +#### Removing an event + +The command `@event` also provides a `/del` switch to remove an event. It takes the same arguments as the `/edit` switch: + +1. The name of the object. +2. The name of the event after an = sign. +3. Optionally a number if more than one event are located there. + +When removed, events are logged, so an administrator can retrieve its content, assuming the `/del` was an error and the administrator has access to log files. + +### The event editor + +When adding or editing an event, the event editor should open. It is basically the same as [EvEditor](https://github.com/evennia/evennia/wiki/EvEditor), which so ressemble VI, but it adds a couple of options to handle indentation. + +Python is a programming language that needs correct indentation. It is not an aesthetic concern, but a requirement to differentiate between blocks. The event editor will try to guess the right level of indentation to make your life easier, but it will not be perfect. + +- If you enter an instruction beginning by `if`, `elif`, or `else`, the editor will automatically increase the level of indentation of the next line. +- If the instruction is an `elif` or `else`, the editor will look for the opening block of `if` and match indentation. +- Blocks `while`, `for`, `try`, `except`, 'finally' obey the same rules. + +There are still some cases when you must tell the editor to reduce or increase indentation. The usual use cases are: + +1. When you close a condition or loop, the editor will not be able to tell. +2. When you want to keep the instruction on several lines, the editor will not bother with indentation. + +In both cases, you should use the `:>` command (increase indentation by one level) and `:<` (decrease indentation by one level). Indentation is always shown when you add a new line in your event. + +In all the cases shown above, you don't need to enter your indentation manually. Just change the indentation whenever needed, don't bother to write spaces or tabulations at the beginning of your line. For instance, you could enter the following lines in your client: + +``` +if character.id == 1: +character.msg("You're the big boss.") +else: +character.msg("I don't know who you are.") +:< +character.msg("This is not inside of the condition.") +``` + +This will produce the following code: + +``` +if character.id == 1: + character.msg("You're the big boss.") +else: + character.msg("I don't know who you are.") + +character.msg("This is not inside of the condition.") +``` + +You can also disable the automatic-indentation mode. Just enter the command `:=`. In this mode, you will have to manually type in the spaces or tabulations, the editor will not indent anything without you asking to do it. This mode can be useful if you copy/paste some code and want to keep the original indentation. + +## Using events + +The following sub-sections describe how to use events for various tasks, from the most simple to the most complex. + +### Standard Python code in events + +This might sound superfluous, considering the previous explanations, but remember you can use standard Python code in your events. Everything that you could do in the source code itself, like changing attributes or aliases, creating or removing objects, can be done through this system. What you will see in the following sub-sections doesn't rely on a new syntax of Python: they add functions and some features, at the best. Events aren't written in softcode, and their syntax might, at first glance, be a bit unfriendly to a user without any programming skills. However, you will probably grasp the basic concept very quickly, and will be able to move beyond simple events in good time. Don't overlook examples, in this documentation, or in your game. + +### The helper functions + +In order to make development a little easier, the event system provides helper functions to be used in events themselves. You don't have to use them, they are just shortcuts. + +The `deny()` function is such a helper. It allows to interrupt the event and the action that called it. In the `can_*` events, it can be used to prevent the action from happening. For instance, in `can_traverse` on exits, it can prevent the user from moving in that direction. One could have a `can_eat` event set on food that would prevent this character from eating this food. Or a `can_say` event in a room that would prevent the character from saying something here. + +Behind the scene, the `deny()` function raises an exception that is being intercepted by the handler of events. Calling this function in events that cannot be stopped may result in errors. + +You could easily add other helper functions. This will greatly depend on the objects you have defined in your game, and how often specific features have to be used by event users. You will find a list of helper functions, their syntax and examples, in the documentation on events specific to you game (see below). + +### Variables in events + +Most events have variables. Variables are just Python variables. 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. The list of variables can change between events, and is always available in the help of the event. When you edit or add a new event, you'll see the help: read it carefully until you're familiar with this event, since it will give you useful information beyond the list of variables. + +### Events with parameters + +Some events are called without parameter. For instance, when a character traverses through an exit, the exit's "traverse" event is called with no argument. In some cases, you can create events 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. The 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 event with the parameter "one": + + @event/add here = say one + +This event will only fire when the user says "one" in this room. + +But what if we want to have an event that would fire if the user says 1 or one? We can provide several parameters, separated by a comma. + + @event/add here = say 1, one + +Or, still more keywords: + + @event/add here = say 1, one, ground + +This time, the user could say "ground" or "one" in the room, and it would fire the event. + +Not all events can take parameters, and these who do have a 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. + +### 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 argument, 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): + +``` +@event here = time 12:00 +# 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. 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 specific 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 + +Events can call other events, either now or a bit later. It is potentially very powerful. + +To use chained events, just use the `call` helper function. 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. Creating exits (opening its doors), waiting a bit, closing them, rolling around and stopping at a different station. That's quite a complex set of events, as it is, but let's only look at the part that opens and closes the doors: + +``` +@event here = time 10:00 +# 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) +# Open the door +to_exit = get(id=23) +to_exit.name = "platform" +to_exit.aliases = ["p"] +to_exit.location = room +to_exit.destination = station +# Create the return exit +back_exit = get(id=24) +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(room, "chain_1", 20) +``` + +This event will: + +1. Be called at 10:00 AM (specify 22:00 to say 10:00 PM). +2. Set an exit between the subway and the station. Notice that the exits already exist (you will 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"? + +``` +@event here = chain_1 +# 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 scene, the `call` function freezes all variables ("room", "station", "to_exit, "back_exit" in our example), so you don't need to define them afterward. + +A word of caution on events that call chained events: it isn't impossible for an event 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()`, 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. + +## Getting help on events + +It is not always the case, but game developers who are using the event system are encouraged to allow the system to create and maintain an automatic help file that contains the basic explanation of events, how to use them and, more importantly, the list of available helpers. This is important, because each game can append new helpers, and this documentation only shows a few, common one: the automatically-generated help file allows you to check what helpers exist and how to use them. If set, you can enter `help events` to see it. If not, you might want to ask a game developer to have access to this information. + +## Errors in events + +There are a lot of ways to make mistakes while writing events. Once you begin, you might encounter syntax errors very often, but leave them behind as you gain in confidence. However, there are still so many ways to trigger errors: passing the wrong arguments to a helper function is only one of many possible examples. + +When an event encounters an error, it stops abruptly and sends the error on a special channel, named "everror", on which you can connect or disconnect should the amount of information be overwhelming. These error messages will contain: + +- The name and ID of the object that encountered the error. +- The name of the event, with possible parameters, that crashed. +- The short error messages (it might not be that short at times). + +The error will also be logged, so an administrator can still access it more completely, seeing the full traceback, which can help to understand the error sometimes. + + 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/scripts.py b/evennia/contrib/events/scripts.py new file mode 100644 index 000000000..593a596e2 --- /dev/null +++ b/evennia/contrib/events/scripts.py @@ -0,0 +1,14 @@ +""" +Scripts for the event system. +""" + +from evennia import DefaultScript + +class EventHandler(DefaultScript): + + """Event handler that contains all events in a global script.""" + + def at_script_creation(self): + self.key = "event_handler" + self.desc = "Global event handler" + self.persistent = True From 74ab1ed03023cfe93509152810b43e46d73a323b Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 8 Mar 2017 21:13:27 -0800 Subject: [PATCH 054/133] Add basic structure of event types and helpers --- evennia/contrib/events/README.md | 6 +-- evennia/contrib/events/exceptions.py | 15 ++++++ evennia/contrib/events/extend.py | 72 ++++++++++++++++++++++++++++ evennia/contrib/events/helpers.py | 53 ++++++++++++++++++++ evennia/contrib/events/scripts.py | 4 ++ 5 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 evennia/contrib/events/exceptions.py create mode 100644 evennia/contrib/events/extend.py create mode 100644 evennia/contrib/events/helpers.py diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md index a4256fd93..1cc8ceed3 100644 --- a/evennia/contrib/events/README.md +++ b/evennia/contrib/events/README.md @@ -122,7 +122,7 @@ This section will explain how to add new helper functions and events. Default events are great but you may need more events to fit with your purposes. For instance, if you have a `yell` command and would like a `can_yell` event in all your rooms. -The way to do this is to add, below your class definition, lines to add these events. The `create_event` function should be called. It takes the following arguments: +The way to do this is to add, below your class definition, lines to add these events. The `create_event_type` function should be called. It takes the following arguments: - The class to have these events (defined above). - The name of the event to add (str). @@ -134,7 +134,7 @@ Here's an example of adding the `can_yell` event to all your rooms: ```python # In typeclasses/rooms.py from evennia import DefaultRoom -from evennia.contrib.events.extend import create_event +from evennia.contrib.events.extend import create_event_type class Room(DefaultRoom): """ @@ -149,7 +149,7 @@ class Room(DefaultRoom): pass # Room events -create_event(Room, "can_yell", ["character", "room", "message"], """ +create_event_type(Room, "can_yell", ["character", "room", "message"], """ Can the character yell in this room? This event is called when a character uses the 'yell' command to yell in this room. This event is called BEFORE the character diff --git a/evennia/contrib/events/exceptions.py b/evennia/contrib/events/exceptions.py new file mode 100644 index 000000000..b33e4a3e2 --- /dev/null +++ b/evennia/contrib/events/exceptions.py @@ -0,0 +1,15 @@ +""" +Module containing the exceptions of the event system. +""" + +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/events/extend.py b/evennia/contrib/events/extend.py new file mode 100644 index 000000000..39e5d8aaf --- /dev/null +++ b/evennia/contrib/events/extend.py @@ -0,0 +1,72 @@ +""" +Functions to extend the event system. + +These funcitons are not helpers (helpers are in a separate module) +and are designed to be used more by developers to add event types. + +""" + +from evennia import logger +from evennia import ScriptDB + +def create_event_type(typeclass, event_name, variables, help_text): + """ + Create a new event type for a specific typeclass. + + Args: + typeclass (type): the class defining tye typeclass to be used. + event_name (str): the name of the event to be added. + variables (list of str): a list of variable names. + help_text (str): a help text of the event. + + Events obey the inheritance hierarchy: if you set an event on + DefaultRoom, for instance, and if your Room typeclass inherits + from DefaultRoom (the default), the event will be available to + all rooms. Objects of the typeclass set in argument will be + able to set one or more events of that name. + + If the event already exists in the typeclass, replace it. + + """ + typeclass_name = typeclass.__module__ + "." + typeclass.__name__ + try: + script = ScriptDB.objects.get(db_key="event_handler") + except ScriptDB.DoesNotExist: + logger.log_err("Can't create event {} in typeclass {}, the " \ + "script handler isn't defined".format(name, typeclass_name)) + return + + # Get the event types for this typeclass + event_types = script.db.event_types.get(typeclass_name, {}) + if not event_types: + script.db.event_types[typeclass_name] = event_types + + # Add or replace the event + event_types[event_name] = (variables, help_text) + +def del_event_type(typeclass, event_name): + """ + Delete the event type for this typeclass. + + Args: + typeclass (type): the class defining the typeclass. + event_name (str): the name of the event to be deleted. + + If you want to delete an event type, you need to remove it from + the typeclass that defined it: other typeclasses in the inheritance + hierarchy are not affected. This method doesn't remove the + already-created events associated with individual objects. + + """ + typeclass_name = typeclass.__module__ + "." + typeclass.__name__ + try: + script = ScriptDB.objects.get(db_key="event_handler") + except ScriptDB.DoesNotExist: + logger.log_err("Can't create event {} in typeclass {}, the " \ + "script handler isn't defined".format(name, typeclass_name)) + return + + # Get the event types for this typeclass + event_types = script.db.event_types.get(typeclass_name, {}) + if event_name in event_types: + del event_types[event_name] diff --git a/evennia/contrib/events/helpers.py b/evennia/contrib/events/helpers.py new file mode 100644 index 000000000..b763d5781 --- /dev/null +++ b/evennia/contrib/events/helpers.py @@ -0,0 +1,53 @@ +""" +Module defining basic helpers for the event system. + + +Hlpers are just Python function that can be used inside of events. They + +""" + +from evennia import ObjectDB +from evennia.contrib.events.exceptions import InterruptEvent + +def deny(): + """ + Deny, that is stop, the event here. + + 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. + + 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 event 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. + + 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. + + """ + try: + object = ObjectDB.objects.get(**kwargs) + except ObjectDB.DoesNotExist: + object = None + + return object diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 593a596e2..5f4af422f 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -12,3 +12,7 @@ class EventHandler(DefaultScript): self.key = "event_handler" self.desc = "Global event handler" self.persistent = True + + # Permanent data to be stored + self.db.event_types = {} + self.db.events = {} From 51bc9ac65a1827516095d0565514e5312e406a42 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Thu, 9 Mar 2017 21:17:41 -0800 Subject: [PATCH 055/133] Add the first event on exit.at_traverse --- evennia/contrib/events/extend.py | 38 ++++++++++- evennia/contrib/events/scripts.py | 98 +++++++++++++++++++++++++++ evennia/contrib/events/typeclasses.py | 40 +++++++++++ 3 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 evennia/contrib/events/typeclasses.py diff --git a/evennia/contrib/events/extend.py b/evennia/contrib/events/extend.py index 39e5d8aaf..40242d452 100644 --- a/evennia/contrib/events/extend.py +++ b/evennia/contrib/events/extend.py @@ -9,6 +9,8 @@ and are designed to be used more by developers to add event types. from evennia import logger from evennia import ScriptDB +hooks = [] + def create_event_type(typeclass, event_name, variables, help_text): """ Create a new event type for a specific typeclass. @@ -37,9 +39,9 @@ def create_event_type(typeclass, event_name, variables, help_text): return # Get the event types for this typeclass - event_types = script.db.event_types.get(typeclass_name, {}) - if not event_types: - script.db.event_types[typeclass_name] = event_types + if typeclass_name not in script.db.event_types: + script.db.event_types[typeclass_name] = {} + event_types = script.db.event_types[typeclass_name] # Add or replace the event event_types[event_name] = (variables, help_text) @@ -70,3 +72,33 @@ def del_event_type(typeclass, event_name): event_types = script.db.event_types.get(typeclass_name, {}) if event_name in event_types: del event_types[event_name] + +def patch_hook(typeclass, method_name): + """Decorator to softly patch a hook in a typeclass.""" + hook = getattr(typeclass, method_name) + def wrapper(method): + """Wrapper around the hook.""" + def overridden_hook(*args, **kwargs): + """Function to call the new hook.""" + # Enforce the old hook as a keyword argument + kwargs["hook"] = hook + ret = method(*args, **kwargs) + return ret + hooks.append((typeclass, method_name, overridden_hook)) + return overridden_hook + return wrapper + +def patch_hooks(): + """ + Patch all the configured hooks. + + This function should be called only once when the event system + has loaded, is set and has defined its patched typeclasses. + It will be called internally by the event system, you shouldn't + call this function in your game. + + """ + while hooks: + typeclass, method_name, new_hook = hooks[0] + setattr(typeclass, method_name, new_hook) + del hooks[0] diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 5f4af422f..6f57c6d41 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -2,7 +2,13 @@ Scripts for the event system. """ +from datetime import datetime +from Queue import Queue + from evennia import DefaultScript +from evennia import logger +from evennia.contrib.events.extend import patch_hooks +from evennia.contrib.events import typeclasses class EventHandler(DefaultScript): @@ -16,3 +22,95 @@ class EventHandler(DefaultScript): # Permanent data to be stored self.db.event_types = {} self.db.events = {} + + def at_start(self): + """Set up the event system.""" + patch_hooks() + + def add_event(self, obj, event_name, code, author=None, valid=True): + """ + Add the specified event. + + Args: + obj (Object): the Evennia typeclassed object to be modified. + event_name (str): the name of the event to add. + code (str): the Python code associated with this event. + author (optional, Character, Player): the author of the event. + valid (optional, bool): should the event be connected? + + This method doesn't check that the event type exists. + + """ + obj_events = self.db.events.get(obj, {}) + if not obj_events: + self.db.events[obj] = {} + obj_events = self.db.events[obj] + + events = obj_events.get(event_name, []) + if not events: + obj_events[event_name] = [] + events = obj_events[event_name] + + # Add the event in the list + events.append({ + "created_on": datetime.now(), + "author": author, + "valid": valid, + "code": code, + }) + + def call_event(self, obj, event_name, *args): + """ + Call the event. + + Args: + obj (Object): the Evennia typeclassed object. + event_name (str): the event name to call. + *args: additional variables for this event. + + Returns: + True to report the event was called without interruption, + False otherwise. + + """ + # First, look for the event type corresponding to this name + # To do so, go back the inheritance tree + event_type = None + event_types = self.db.event_types + classes = Queue() + classes.put(type(obj)) + while not classes.empty(): + typeclass = classes.get() + typeclass_name = typeclass.__module__ + "." + typeclass.__name__ + event_type = event_types.get(typeclass_name, {}).get(event_name) + if event_type: + break + else: + # Look for the parent classes + for parent in typeclass.__bases__: + classes.put(parent) + + # If there is still no event_type + if not event_type: + logger.log_err("The event {} for the object {} (typeclass " \ + "{}) can't be found".format(event_name, obj, type(obj))) + return False + + # Prepare the locals + locals = {} + for i, variable in enumerate(event_type[0]): + try: + locals[variable] = args[i] + except IndexError: + logger.log_err("event {} of {} ({}): need variable " \ + "{} in position {}".format(event_name, obj, + type(obj), variable, i)) + return False + + # Now execute all the valid events linked at this address + events = self.db.events.get(obj, {}).get(event_name, []) + for event in events: + if not event["valid"]: + continue + + exec(event["code"], locals, locals) diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py new file mode 100644 index 000000000..94ed67e76 --- /dev/null +++ b/evennia/contrib/events/typeclasses.py @@ -0,0 +1,40 @@ +""" +Patched typeclasses for Evennia. +""" + +from evennia import DefaultCharacter, DefaultExit +from evennia import ScriptDB +from evennia.contrib.events.extend import create_event_type, patch_hook +from evennia.utils.utils import inherits_from + +class PatchedExit(object): + + """Patched exit to patch some hooks of DefaultExit.""" + + @staticmethod + @patch_hook(DefaultExit, "at_traverse") + def at_traverse(exit, traversing_object, target_location, hook=None): + """ + 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. + + """ + if inherits_from(traversing_object, DefaultCharacter): + script = ScriptDB.objects.get(db_key="event_handler") + script.call_event(exit, "at_traverse", traversing_object, + exit, exit.location) + + hook(exit, traversing_object, target_location) + +# Default events +create_event_type(DefaultExit, "at_traverse", ["character", "exit", "room"], + """When traversing""") From 0d7b1cb2beb69347fdde2b4cf2ff336ab5e43a87 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 12 Mar 2017 13:14:33 -0700 Subject: [PATCH 056/133] Add the basic of the `@event` command --- evennia/contrib/events/commands.py | 200 +++++++++++++++++++++++++++++ evennia/contrib/events/extend.py | 10 ++ evennia/contrib/events/scripts.py | 50 +++++--- 3 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 evennia/contrib/events/commands.py diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py new file mode 100644 index 000000000..e180f5f36 --- /dev/null +++ b/evennia/contrib/events/commands.py @@ -0,0 +1,200 @@ +""" +Module containing the commands of the event system. +""" + +from django.conf import settings +from evennia import Command +from evennia.contrib.events.extend import get_event_handler +from evennia.utils.evtable import EvTable +from evennia.utils.utils import class_from_module + +COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) + +# Permissions +WITH_VALIDATION = getattr(settings, "EVENTS_WITH_VALIDATION", None) +WITHOUT_VALIDATION = getattr(settings, "EVENTS_WITHOUT_VALIDATION", + "immortals") +VALIDATING = getattr(settings, "EVENTS_VALIDATING", "immortals") + +# Split help file +BASIC_HELP = "Add, edit or delete events." + +BASIC_USAGES = [ + "@event object name [= event name]", + "@event/add object name = event name [parameters]", + "@event/edit object name = event name [event number]", + "@event/del object name = event name [event number]", +] + +BASIC_SWITCHES = [ + "add - add and edit a new event", + "edit - edit an existing event", + "del - delete an existing event", +] + +VALIDATOR_USAGES = [ + "@event/accept [object name = event name [event number]]", +] + +VALIDATOR_SWITCHES = [ + "accept - show events to be validated or accept one", +] + +BASIC_TEXT = """ +This command is used to manipulate events. An event can be linked to +an object, to fire at a specific moment. You can use the command without +switches to see what event are active on an object: + @event self +You can also specify an event name if you want the list of events associated +with this object of this name: + @event north = can_traverse +You can also add, edit or remove events using the add, edit or del switches. +""" + +VALIDATOR_TEXT = """ +You can also use this command to validate events. Depending on your game +setting, some users might be allowed to add new events, but these events +will not be fired until you accept them. To see the events needing +validation, enter the /accept switch without argument: + @event/accept +A table will show you the events that are not validated yet, who created +it and when. You can then accept a specific event: + @event here = enter +Or, if more than one events are connected here, specify the number: + @event here = enter 3 +Use the /del switch to remove events that should not be connected. +""" + +class CmdEvent(COMMAND_DEFAULT_CLASS): + + """Command to edit events.""" + + key = "@event" + locks = "cmd:perm({})".format(VALIDATING) + aliases = ["@events", "@ev"] + 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(events_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) + + # First and foremost, get the event handler + self.handler = get_event_handler() + if self.handler is None: + caller.msg("The event handler is not running, can't " \ + "access the event system.") + return + + # Before the equal sign is always an object name + obj = None + if self.args.strip(): + obj = caller.search(self.lhs) + if not obj: + return + + # Switches are mutually exclusive + switch = self.switches and self.switches[0] or "" + if switch == "": + if not obj: + caller.msg("Specify an object's name or #ID.") + return + + self.list_events(obj) + elif switch == "add": + if not obj: + caller.msg("Specify an object's name or #ID.") + return + + self.add_event(obj) + elif switch == "edit": + if not obj: + caller.msg("Specify an object's name or #ID.") + return + + self.edit_event(obj) + elif switch == "del": + if not obj: + caller.msg("Specify an object's name or #ID.") + return + + self.del_event(obj) + elif switch == "accept" and validator: + self.accept_event(obj) + else: + caller.msg("Mutually exclusive or invalid switches were " \ + "used, cannot proceed.") + + def list_events(self, obj): + """Display the list of events connected to the object.""" + events = self.handler.get_events(obj) + types = self.handler.get_event_types(obj) + table = EvTable("Event name", "Number", "Lines", "Description", + width=78) + for name, infos in sorted(types.items()): + number = len(events.get(name, [])) + lines = sum(len(e["code"].splitlines()) for e in \ + events.get(name, [])) + description = infos[1].splitlines()[0] + table.add_row(name, number, lines, description) + + table.reformat_column(1, align="r") + table.reformat_column(2, align="r") + self.msg(table) + + def add_event(self, obj): + """Add an event.""" + self.msg("Calling add.") + + def edit_event(self, obj): + """Add an event.""" + self.msg("Calling edit.") + + def del_event(self, obj): + """Add an event.""" + self.msg("Calling del.") + + def accept_event(self, obj): + """Add an event.""" + self.msg("Calling accept.") diff --git a/evennia/contrib/events/extend.py b/evennia/contrib/events/extend.py index 40242d452..7330ceee2 100644 --- a/evennia/contrib/events/extend.py +++ b/evennia/contrib/events/extend.py @@ -11,6 +11,16 @@ from evennia import ScriptDB hooks = [] +def get_event_handler(): + """Return the event handler or None.""" + try: + script = ScriptDB.objects.get(db_key="event_handler") + except ScriptDB.DoesNotExist: + logger.log_err("Can't get the event handler.") + script = None + + return script + def create_event_type(typeclass, event_name, variables, help_text): """ Create a new event type for a specific typeclass. diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 6f57c6d41..2ad087e3a 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -27,6 +27,39 @@ class EventHandler(DefaultScript): """Set up the event system.""" patch_hooks() + def get_events(self, obj): + """ + Return a dictionary of the object's events. + + Args: + obj (Object): the connected objects. + + """ + return self.db.events.get(obj, {}) + + def get_event_types(self, obj): + """ + Return a dictionary of event types on this object. + + Args: + obj (Object): the connected object. + + """ + types = {} + event_types = self.db.event_types + classes = Queue() + classes.put(type(obj)) + while not classes.empty(): + typeclass = classes.get() + typeclass_name = typeclass.__module__ + "." + typeclass.__name__ + types.update(event_types.get(typeclass_name, {})) + + # Look for the parent classes + for parent in typeclass.__bases__: + classes.put(parent) + + return types + def add_event(self, obj, event_name, code, author=None, valid=True): """ Add the specified event. @@ -75,22 +108,7 @@ class EventHandler(DefaultScript): """ # First, look for the event type corresponding to this name # To do so, go back the inheritance tree - event_type = None - event_types = self.db.event_types - classes = Queue() - classes.put(type(obj)) - while not classes.empty(): - typeclass = classes.get() - typeclass_name = typeclass.__module__ + "." + typeclass.__name__ - event_type = event_types.get(typeclass_name, {}).get(event_name) - if event_type: - break - else: - # Look for the parent classes - for parent in typeclass.__bases__: - classes.put(parent) - - # If there is still no event_type + event_type = self.get_event_types(obj).get(event_name) if not event_type: logger.log_err("The event {} for the object {} (typeclass " \ "{}) can't be found".format(event_name, obj, type(obj))) From 4bdee14adb93031392fbdb0640fc7aa2ad36972b Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 12 Mar 2017 19:26:19 -0700 Subject: [PATCH 057/133] Add the /add and /edit switches to the @event command --- evennia/contrib/events/commands.py | 196 +++++++++++++++++++++----- evennia/contrib/events/scripts.py | 53 ++++++- evennia/contrib/events/typeclasses.py | 14 +- 3 files changed, 222 insertions(+), 41 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index e180f5f36..61809a27a 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -2,12 +2,16 @@ Module containing the commands of the event system. """ +from datetime import datetime + from django.conf import settings from evennia import Command from evennia.contrib.events.extend import get_event_handler +from evennia.utils.eveditor import EvEditor from evennia.utils.evtable import EvTable -from evennia.utils.utils import class_from_module +from evennia.utils.utils import class_from_module, time_format +COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) # Permissions @@ -120,81 +124,203 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): lock = "perm({}) or perm(events_validating)".format(VALIDATING) validator = caller.locks.check_lockstring(caller, lock) - # First and foremost, get the event handler + # First and foremost, get the event handler and set other variables self.handler = get_event_handler() + self.obj = None + rhs = self.rhs or "" + self.event_name, sep, self.parameters = rhs.partition(" ") + self.event_name = self.event_name.lower() + self.is_validator = validator if self.handler is None: caller.msg("The event handler is not running, can't " \ "access the event system.") return # Before the equal sign is always an object name - obj = None if self.args.strip(): - obj = caller.search(self.lhs) - if not obj: + 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 == "": - if not obj: + if not self.obj: caller.msg("Specify an object's name or #ID.") return - self.list_events(obj) + self.list_events() elif switch == "add": - if not obj: + if not self.obj: caller.msg("Specify an object's name or #ID.") return - self.add_event(obj) + self.add_event() elif switch == "edit": - if not obj: + if not self.obj: caller.msg("Specify an object's name or #ID.") return - self.edit_event(obj) + self.edit_event() elif switch == "del": - if not obj: + if not self.obj: caller.msg("Specify an object's name or #ID.") return - self.del_event(obj) + self.del_event() elif switch == "accept" and validator: - self.accept_event(obj) + self.accept_event() else: caller.msg("Mutually exclusive or invalid switches were " \ "used, cannot proceed.") - def list_events(self, obj): + def list_events(self): """Display the list of events connected to the object.""" + obj = self.obj + event_name = self.event_name events = self.handler.get_events(obj) types = self.handler.get_event_types(obj) - table = EvTable("Event name", "Number", "Lines", "Description", - width=78) - for name, infos in sorted(types.items()): - number = len(events.get(name, [])) - lines = sum(len(e["code"].splitlines()) for e in \ - events.get(name, [])) - description = infos[1].splitlines()[0] - table.add_row(name, number, lines, description) - table.reformat_column(1, align="r") - table.reformat_column(2, align="r") - self.msg(table) + if event_name: + # Check that the event name can be found in this object + created = events.get(event_name) + if created is None: + self.msg("No event {} has been set on {}.".format(event_name, obj)) + return - def add_event(self, obj): + # Create the table + cols = ["Number", "Author", "Updated"] + if self.is_validator: + cols.append("Valid") + + table = EvTable(*cols, width=78) + now = datetime.now() + for i, event in enumerate(created): + author = event.get("author") + author = author.key if author else "|gUnknown|n" + updated_on = event.get("updated_on") + if updated_on is None: + updated_on = event.get("created_on") + + if updated_on: + updated_on = time_format( + (now - updated_on).total_seconds(), 1) + else: + updated_on = "|gUnknown|n" + + row = [str(i + 1), author, updated_on] + if self.is_validator: + row.append("Yes" if event.get("valid") else "no") + table.add_row(*row) + + table.reformat_column(0, align="r") + self.msg(table) + else: + table = EvTable("Event name", "Number", "Lines", "Description", + width=78) + for name, infos in sorted(types.items()): + number = len(events.get(name, [])) + lines = sum(len(e["code"].splitlines()) for e in \ + events.get(name, [])) + description = infos[1].splitlines()[0] + table.add_row(name, number, lines, description) + + table.reformat_column(1, align="r") + table.reformat_column(2, align="r") + self.msg(table) + + def add_event(self): """Add an event.""" - self.msg("Calling add.") + obj = self.obj + event_name = self.event_name + types = self.handler.get_event_types(obj) - def edit_event(self, obj): - """Add an event.""" - self.msg("Calling edit.") + # Check that the event exists + if not event_name in types: + self.msg("The event name {} can't be found in {} of " \ + "typeclass {}.".format(event_name, obj, type(obj))) + return - def del_event(self, obj): - """Add an event.""" + definition = types[event_name] + description = definition[1] + self.msg(description) + + # Open the editor + event = self.handler.add_event(obj, event_name, "", + self.caller, False) + self.caller.db._event = event + EvEditor(self.caller, loadfunc=_ev_load, savefunc=_ev_save, + quitfunc=_ev_quit, key="Event {} of {}".format( + event_name, obj), persistent=True, codefunc=_ev_save) + + def edit_event(self): + """Edit an event.""" + obj = self.obj + event_name = self.event_name + parameters = self.parameters + events = self.handler.get_events(obj) + types = self.handler.get_event_types(obj) + + # Check that the event exists + if not event_name in events: + self.msg("The event name {} can't be found in {}.".format( + event_name, obj)) + return + + # Check that the parameter points to an existing event + try: + parameters = int(parameters) - 1 + assert parameters >= 0 + event = events[event_name][parameters] + except (AssertionError, ValueError): + self.msg("The event {} {} cannot be found in {}.".format( + event_name, parameters, obj)) + return + + definition = types[event_name] + description = definition[1] + self.msg(description) + + # Open the editor + event = dict(event) + event["obj"] = obj + event["name"] = event_name + event["number"] = parameters + self.caller.db._event = event + EvEditor(self.caller, loadfunc=_ev_load, savefunc=_ev_save, + quitfunc=_ev_quit, key="Event {} of {}".format( + event_name, obj), persistent=True, codefunc=_ev_save) + + def del_event(self): + """Delete an event.""" + obj = self.obj self.msg("Calling del.") - def accept_event(self, obj): - """Add an event.""" + def accept_event(self): + """Accept an event.""" + obj = self.obj self.msg("Calling accept.") + +# Private functions to handle editing +def _ev_load(caller): + return caller.db._event and caller.db._event.get("code", "") or "" + +def _ev_save(caller, buf): + """Save and add the event.""" + lock = "perm({}) or perm(events_without_validation)".format( + WITHOUT_VALIDATION) + autovalid = caller.locks.check_lockstring(caller, lock) + event = caller.db._event + handler = get_event_handler() + if not handler or not event or not all(key in event for key in \ + ("obj", "name", "number", "valid")): + caller.msg("Couldn't save this event.") + return False + + handler.edit_event(event["obj"], event["name"], event["number"], buf, + caller, valid=autovalid) + return True + +def _ev_quit(caller): + del caller.db._event + caller.msg("Exited the code editor.") diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 2ad087e3a..46a50e1fe 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -7,8 +7,10 @@ from Queue import Queue from evennia import DefaultScript from evennia import logger +from evennia.contrib.events.exceptions import InterruptEvent from evennia.contrib.events.extend import patch_hooks from evennia.contrib.events import typeclasses +from evennia.utils.utils import all_from_module class EventHandler(DefaultScript): @@ -60,7 +62,7 @@ class EventHandler(DefaultScript): return types - def add_event(self, obj, event_name, code, author=None, valid=True): + def add_event(self, obj, event_name, code, author=None, valid=False): """ Add the specified event. @@ -92,6 +94,46 @@ class EventHandler(DefaultScript): "code": code, }) + definition = dict(events[-1]) + definition["obj"] = obj + definition["name"] = event_name + definition["number"] = len(events) - 1 + return definition + + def edit_event(self, obj, event_name, number, code, author=None, + valid=False): + """ + Edit the specified event. + + Args: + obj (Object): the Evennia typeclassed object to be modified. + event_name (str): the name of the event to add. + number (int): the event number to be changed. + code (str): the Python code associated with this event. + author (optional, Character, Player): the author of the event. + valid (optional, bool): should the event be connected? + + This method doesn't check that the event type exists. + + """ + obj_events = self.db.events.get(obj, {}) + if not obj_events: + self.db.events[obj] = {} + obj_events = self.db.events[obj] + + events = obj_events.get(event_name, []) + if not events: + obj_events[event_name] = [] + events = obj_events[event_name] + + # Edit the event + events[number].update({ + "updated_on": datetime.now(), + "updated_by": author, + "valid": valid, + "code": code, + }) + def call_event(self, obj, event_name, *args): """ Call the event. @@ -115,7 +157,7 @@ class EventHandler(DefaultScript): return False # Prepare the locals - locals = {} + locals = all_from_module("evennia.contrib.events.helpers") for i, variable in enumerate(event_type[0]): try: locals[variable] = args[i] @@ -131,4 +173,9 @@ class EventHandler(DefaultScript): if not event["valid"]: continue - exec(event["code"], locals, locals) + try: + exec(event["code"], locals, locals) + except InterruptEvent: + return False + + return True diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 94ed67e76..c66df484f 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -30,11 +30,19 @@ class PatchedExit(object): """ if inherits_from(traversing_object, DefaultCharacter): script = ScriptDB.objects.get(db_key="event_handler") - script.call_event(exit, "at_traverse", traversing_object, + allow = script.call_event(exit, "can_traverse", traversing_object, exit, exit.location) + if not allow: + return hook(exit, traversing_object, target_location) # Default events -create_event_type(DefaultExit, "at_traverse", ["character", "exit", "room"], - """When traversing""") +create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], + """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 + using this exit for the time being. The 'character' variable + contains the character who wants to traverse through this exit. + The 'exit' variable contains the exit, the 'room' variable + contains the room in which the character and exit are.""") From 88516630db0f9f363ad9225a02b0b0d8198d7eb3 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 13 Mar 2017 11:46:16 -0700 Subject: [PATCH 058/133] Set event types in non-persistent data for the event handler --- evennia/contrib/events/extend.py | 42 ++++++++++++++++++++----------- evennia/contrib/events/scripts.py | 7 +++--- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/evennia/contrib/events/extend.py b/evennia/contrib/events/extend.py index 7330ceee2..24f228054 100644 --- a/evennia/contrib/events/extend.py +++ b/evennia/contrib/events/extend.py @@ -10,6 +10,7 @@ from evennia import logger from evennia import ScriptDB hooks = [] +event_types = [] def get_event_handler(): """Return the event handler or None.""" @@ -41,20 +42,7 @@ def create_event_type(typeclass, event_name, variables, help_text): """ typeclass_name = typeclass.__module__ + "." + typeclass.__name__ - try: - script = ScriptDB.objects.get(db_key="event_handler") - except ScriptDB.DoesNotExist: - logger.log_err("Can't create event {} in typeclass {}, the " \ - "script handler isn't defined".format(name, typeclass_name)) - return - - # Get the event types for this typeclass - if typeclass_name not in script.db.event_types: - script.db.event_types[typeclass_name] = {} - event_types = script.db.event_types[typeclass_name] - - # Add or replace the event - event_types[event_name] = (variables, help_text) + event_types.append((typeclass_name, event_name, variables, help_text)) def del_event_type(typeclass, event_name): """ @@ -79,7 +67,7 @@ def del_event_type(typeclass, event_name): return # Get the event types for this typeclass - event_types = script.db.event_types.get(typeclass_name, {}) + event_types = script.ndb.event_types.get(typeclass_name, {}) if event_name in event_types: del event_types[event_name] @@ -112,3 +100,27 @@ def patch_hooks(): typeclass, method_name, new_hook = hooks[0] setattr(typeclass, method_name, new_hook) del hooks[0] + +def connect_event_types(): + """ + Connect the event types when the script runs. + + This method should be called automatically by the event handler + (the script). + + """ + try: + script = ScriptDB.objects.get(db_key="event_handler") + except ScriptDB.DoesNotExist: + logger.log_err("Can't connect event types, the event handler " \ + "cannot be found.") + return + + for typeclass_name, event_name, variables, help_text in event_types: + # Get the event types for this typeclass + if typeclass_name not in script.ndb.event_types: + script.ndb.event_types[typeclass_name] = {} + types = script.ndb.event_types[typeclass_name] + + # Add or replace the event + types[event_name] = (variables, help_text) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 46a50e1fe..a1b3b2d54 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -8,7 +8,7 @@ from Queue import Queue from evennia import DefaultScript from evennia import logger from evennia.contrib.events.exceptions import InterruptEvent -from evennia.contrib.events.extend import patch_hooks +from evennia.contrib.events.extend import connect_event_types, patch_hooks from evennia.contrib.events import typeclasses from evennia.utils.utils import all_from_module @@ -22,11 +22,12 @@ class EventHandler(DefaultScript): self.persistent = True # Permanent data to be stored - self.db.event_types = {} self.db.events = {} def at_start(self): """Set up the event system.""" + self.ndb.event_types = {} + connect_event_types() patch_hooks() def get_events(self, obj): @@ -48,7 +49,7 @@ class EventHandler(DefaultScript): """ types = {} - event_types = self.db.event_types + event_types = self.ndb.event_types classes = Queue() classes.put(type(obj)) while not classes.empty(): From 5e43f1370f97ff82f5bfd6fbdb7acbb6b084ee29 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 13 Mar 2017 13:16:46 -0700 Subject: [PATCH 059/133] Set @event/edit to be more clever when selecting events --- evennia/contrib/events/commands.py | 51 ++++++++++++++++++++------- evennia/contrib/events/extend.py | 3 ++ evennia/contrib/events/typeclasses.py | 30 +++++++++++++--- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 61809a27a..7a761ec52 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -123,6 +123,9 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): 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 event handler and set other variables self.handler = get_event_handler() @@ -131,6 +134,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): self.event_name, sep, self.parameters = rhs.partition(" ") self.event_name = self.event_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.") @@ -210,23 +214,23 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): row = [str(i + 1), author, updated_on] if self.is_validator: - row.append("Yes" if event.get("valid") else "no") + row.append("Yes" if event.get("valid") else "No") table.add_row(*row) table.reformat_column(0, align="r") self.msg(table) else: - table = EvTable("Event name", "Number", "Lines", "Description", + table = EvTable("Event name", "Number", "Description", width=78) for name, infos in sorted(types.items()): number = len(events.get(name, [])) lines = sum(len(e["code"].splitlines()) for e in \ events.get(name, [])) + no = "{} ({})".format(number, lines) description = infos[1].splitlines()[0] - table.add_row(name, number, lines, description) + table.add_row(name, no, description) - table.reformat_column(1, align="r") - table.reformat_column(2, align="r") + table.reformat_column(1, width=10, align="r") self.msg(table) def add_event(self): @@ -261,22 +265,43 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): events = self.handler.get_events(obj) types = self.handler.get_event_types(obj) + # If no event name is specified, display the list of events + if not event_name: + self.list_events() + return + # Check that the event exists if not event_name in events: self.msg("The event name {} can't be found in {}.".format( event_name, obj)) return - # Check that the parameter points to an existing event - try: - parameters = int(parameters) - 1 - assert parameters >= 0 - event = events[event_name][parameters] - except (AssertionError, ValueError): - self.msg("The event {} {} cannot be found in {}.".format( - event_name, parameters, obj)) + # If there's only one event, just edit it + if len(events[event_name]) == 1: + event = events[event_name][0] + else: + if not parameters: + self.msg("Which event do you wish to edit? Specify a number.") + self.list_events() + return + + # Check that the parameter points to an existing event + try: + parameters = int(parameters) - 1 + assert parameters >= 0 + event = events[event_name][parameters] + except (AssertionError, ValueError): + self.msg("The event {} {} cannot be found in {}.".format( + event_name, parameters, obj)) + return + + # If caller can't edit without validation, forbid editing + # others' works + if not self.autovalid and event["author"] is not self.caller: + self.msg("You cannot edit this event created by someone else.") return + # Check the definition of the event definition = types[event_name] description = definition[1] self.msg(description) diff --git a/evennia/contrib/events/extend.py b/evennia/contrib/events/extend.py index 24f228054..a0fa28475 100644 --- a/evennia/contrib/events/extend.py +++ b/evennia/contrib/events/extend.py @@ -6,6 +6,8 @@ and are designed to be used more by developers to add event types. """ +from textwrap import dedent + from evennia import logger from evennia import ScriptDB @@ -123,4 +125,5 @@ def connect_event_types(): types = script.ndb.event_types[typeclass_name] # Add or replace the event + help_text = dedent(help_text.strip("\n")) types[event_name] = (variables, help_text) diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index c66df484f..00bcd7443 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -28,8 +28,9 @@ class PatchedExit(object): target_location (Object): Where target is going. """ - if inherits_from(traversing_object, DefaultCharacter): - script = ScriptDB.objects.get(db_key="event_handler") + is_character = inherits_from(traversing_object, DefaultCharacter) + script = ScriptDB.objects.get(db_key="event_handler") + if is_character: allow = script.call_event(exit, "can_traverse", traversing_object, exit, exit.location) if not allow: @@ -37,12 +38,33 @@ class PatchedExit(object): hook(exit, traversing_object, target_location) + # After traversing + if is_character: + script.call_event(exit, "traverse", traversing_object, + exit, exit.location, exit.destination) + + # Default events create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], - """Can the character traverse through this exit? + """ + 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 using this exit for the time being. The 'character' variable contains the character who wants to traverse through this exit. The 'exit' variable contains the exit, the 'room' variable - contains the room in which the character and exit are.""") + contains the room in which the character and exit are. +""") +create_event_type(DefaultExit, "traverse", ["character", "exit", + "origin", "destination"], """ + 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. + The 'character' variable contains the character who has traversed + through this exit. The 'exit' variable contains the exit, the + 'origin' variable contains the room in which the character was + before traversing, while 'destination' contains the room in which + the character now is. + """) From 93c7b25fc4c9657405b6b5d93b23ad0ee5b5589f Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 13 Mar 2017 18:15:03 -0700 Subject: [PATCH 060/133] Add the @event/accept switch --- evennia/contrib/events/commands.py | 88 ++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 7a761ec52..6abd35328 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -182,6 +182,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): """Display the list of events connected to the object.""" obj = self.obj event_name = self.event_name + parameters = self.parameters events = self.handler.get_events(obj) types = self.handler.get_event_types(obj) @@ -192,12 +193,51 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): self.msg("No event {} has been set on {}.".format(event_name, obj)) return - # Create the table + if parameters: + # Check that the parameter points to an existing event + try: + parameters = int(parameters) - 1 + assert parameters >= 0 + event = events[event_name][parameters] + except (AssertionError, ValueError): + self.msg("The event {} {} cannot be found in {}.".format( + event_name, parameters, obj)) + return + + # Display the events' details + author = event.get("author") + author = author.key if author else "|gUnknown|n" + updated_by = event.get("updated_by") + updated_by = updated_by.key if updated_by else "|gUnknown|n" + created_on = event.get("created_on") + created_on = created_on.strftime("%Y-%m-%d %H:%M:%S") \ + if created_on else "|gUnknown|n" + updated_on = event.get("updated_on") + updated_on = updated_on.strftime("%Y-%m-%d %H:%M:%S") \ + if updated_on else "|gUnknown|n" + number = parameters + 1 + msg = "Event {} {} of {}:".format(event_name, number, obj) + msg += "\nCreated by {} at {}.".format(author, created_on) + msg += "\nUpdated by {} at {}".format(updated_by, updated_on) + + if self.is_validator: + if event.get("valid"): + msg += "\nThis event is |rconnected|n and active." + else: + msg += "\nThis event |rhasn't been|n accepted yet." + + msg += "\nEvent code:\n " + msg += "\n ".join([l for l in event["code"].splitlines()]) + self.msg(msg) + return + + # No parameter has been specified, display the table of events cols = ["Number", "Author", "Updated"] if self.is_validator: cols.append("Valid") table = EvTable(*cols, width=78) + table.reformat_column(0, align="r") now = datetime.now() for i, event in enumerate(created): author = event.get("author") @@ -217,11 +257,13 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): row.append("Yes" if event.get("valid") else "No") table.add_row(*row) - table.reformat_column(0, align="r") self.msg(table) else: table = EvTable("Event name", "Number", "Description", - width=78) + 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, infos in sorted(types.items()): number = len(events.get(name, [])) lines = sum(len(e["code"].splitlines()) for e in \ @@ -230,7 +272,6 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): description = infos[1].splitlines()[0] table.add_row(name, no, description) - table.reformat_column(1, width=10, align="r") self.msg(table) def add_event(self): @@ -324,7 +365,44 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): def accept_event(self): """Accept an event.""" obj = self.obj - self.msg("Calling accept.") + event_name = self.event_name + parameters = self.parameters + events = self.handler.get_events(obj) + types = self.handler.get_event_types(obj) + + # If no event name is specified, display the list of events + if not event_name: + self.list_events() + return + + # Check that the event exists + if not event_name in events: + self.msg("The event name {} can't be found in {}.".format( + event_name, obj)) + return + + if not parameters: + self.msg("Which event do you wish to accept? Specify a number.") + self.list_events() + return + + # Check that the parameter points to an existing event + try: + parameters = int(parameters) - 1 + assert parameters >= 0 + event = events[event_name][parameters] + except (AssertionError, ValueError): + self.msg("The event {} {} cannot be found in {}.".format( + event_name, parameters, obj)) + return + + # Accept the event + if event["valid"]: + self.msg("This event has already been accepted.") + else: + event["valid"] = True + self.msg("The event {} {} of {} has been accepted.".format( + event_name, parameters, obj)) # Private functions to handle editing def _ev_load(caller): From f0d5bec05fa37994e03825a263f154f35f2dad04 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 14 Mar 2017 10:12:15 -0700 Subject: [PATCH 061/133] The @event/accept can now be used without parameters to see all events to be accepted --- evennia/contrib/events/commands.py | 37 +++++++++++++++++++++++++++++- evennia/contrib/events/scripts.py | 28 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 6abd35328..355427db4 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -367,6 +367,41 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): obj = self.obj event_name = self.event_name parameters = self.parameters + + # If no object, display the list of events 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: + events = self.handler.db.events.get(obj, {}).get(name) + if events is None: + continue + + try: + event = events[number] + except IndexError: + continue + + type_name = obj.typeclass_path.split(".")[-1] + by = event.get("updated_by") + by = by.key if by else "|gUnknown|n" + updated_on = event.get("updated_on") + if updated_on is None: + updated_on = event.get("created_on") + + if updated_on: + updated_on = time_format( + (now - updated_on).total_seconds(), 1) + else: + updated_on = "|gUnknown|n" + + table.add_row(obj.id, type_name, obj, name, by, updated_on) + self.msg(table) + return + + # An object was specified events = self.handler.get_events(obj) types = self.handler.get_event_types(obj) @@ -400,7 +435,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): if event["valid"]: self.msg("This event has already been accepted.") else: - event["valid"] = True + self.handler.accept_event(obj, event_name, parameters) self.msg("The event {} {} of {} has been accepted.".format( event_name, parameters, obj)) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index a1b3b2d54..ce87046c8 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -23,6 +23,7 @@ class EventHandler(DefaultScript): # Permanent data to be stored self.db.events = {} + self.db.to_valid = [] def at_start(self): """Set up the event system.""" @@ -95,6 +96,11 @@ class EventHandler(DefaultScript): "code": code, }) + # If not valid, set it in 'to_valid' + if not valid: + self.db.to_valid.append((obj, event_name, len(events) - 1)) + + # Build the definition to return (a dictionary) definition = dict(events[-1]) definition["obj"] = obj definition["name"] = event_name @@ -135,6 +141,28 @@ class EventHandler(DefaultScript): "code": code, }) + # If not valid, set it in 'to_valid' + if not valid and (obj, event_name, number) not in self.db.to_valid: + self.db.to_valid.append((obj, event_name, number)) + + def accept_event(self, obj, event_name, number): + """ + Valid an event. + + Args: + obj (Object): the object containing the event. + event_name (str): the name of the event. + number (int): the number of the event. + + """ + obj_events = self.db.events.get(obj, {}) + events = obj_events.get(event_name, []) + + # Accept and connect the event + events[number].update({"valid": True}) + if (obj, event_name, number) in self.db.to_valid: + self.db.to_valid.remove((obj, event_name, number)) + def call_event(self, obj, event_name, *args): """ Call the event. From 1f4095c62504c9b9c03a56389c45632eeb83a3e6 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 14 Mar 2017 12:31:53 -0700 Subject: [PATCH 062/133] Rename the 'extend' module in 'custom' for events --- evennia/contrib/events/README.md | 4 +-- evennia/contrib/events/commands.py | 2 +- .../contrib/events/{extend.py => custom.py} | 0 evennia/contrib/events/scripts.py | 2 +- evennia/contrib/events/typeclasses.py | 28 +++++++++++++++++-- 5 files changed, 29 insertions(+), 7 deletions(-) rename evennia/contrib/events/{extend.py => custom.py} (100%) diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md index 1cc8ceed3..e23a3765c 100644 --- a/evennia/contrib/events/README.md +++ b/evennia/contrib/events/README.md @@ -134,7 +134,7 @@ Here's an example of adding the `can_yell` event to all your rooms: ```python # In typeclasses/rooms.py from evennia import DefaultRoom -from evennia.contrib.events.extend import create_event_type +from evennia.contrib.events.custom import create_event_type class Room(DefaultRoom): """ @@ -219,7 +219,7 @@ A helper function is really a Python function. Its docstring should be sufficie ### Adding new typeclasses -Adding a new typeclass is not different from extending one, and will obey to the same rules: define the class as you have been accustomed to doing, and create the events with `create_event` under the class definition. +Adding a new typeclass is not different from ing one, and will obey to the same rules: define the class as you have been accustomed to doing, and create the events with `create_event` under the class definition. Note: events obey the inheritance hierarchy: if you define events on the `Room` class, then create a typeclass inheriting from `Room`, the objects of this latter typeclass will have events of both typeclasses. diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 355427db4..42b032aba 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -6,7 +6,7 @@ from datetime import datetime from django.conf import settings from evennia import Command -from evennia.contrib.events.extend import get_event_handler +from evennia.contrib.events.custom import get_event_handler from evennia.utils.eveditor import EvEditor from evennia.utils.evtable import EvTable from evennia.utils.utils import class_from_module, time_format diff --git a/evennia/contrib/events/extend.py b/evennia/contrib/events/custom.py similarity index 100% rename from evennia/contrib/events/extend.py rename to evennia/contrib/events/custom.py diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index ce87046c8..058dc17fd 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -8,7 +8,7 @@ from Queue import Queue from evennia import DefaultScript from evennia import logger from evennia.contrib.events.exceptions import InterruptEvent -from evennia.contrib.events.extend import connect_event_types, patch_hooks +from evennia.contrib.events.custom import connect_event_types, patch_hooks from evennia.contrib.events import typeclasses from evennia.utils.utils import all_from_module diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 00bcd7443..cefa1a6b7 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -2,9 +2,9 @@ Patched typeclasses for Evennia. """ -from evennia import DefaultCharacter, DefaultExit +from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom from evennia import ScriptDB -from evennia.contrib.events.extend import create_event_type, patch_hook +from evennia.contrib.events.custom import create_event_type, patch_hook from evennia.utils.utils import inherits_from class PatchedExit(object): @@ -44,7 +44,8 @@ class PatchedExit(object): exit, exit.location, exit.destination) -# Default events +## Default events +# Exit events create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], """ Can the character traverse through this exit? @@ -68,3 +69,24 @@ create_event_type(DefaultExit, "traverse", ["character", "exit", before traversing, while 'destination' contains the room in which the character now is. """) + +# Room events +create_event_type(DefaultRoom, "time", ["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 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. + time: a string containing the current time. +""") + From e898ee0ec2582cd2cb3bc9091f12cc396e5a717c Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 14 Mar 2017 20:56:55 -0700 Subject: [PATCH 063/133] Add the time-related events and events with parameters --- evennia/contrib/events/commands.py | 2 +- evennia/contrib/events/custom.py | 94 +++++++++++++++++++++++++-- evennia/contrib/events/scripts.py | 73 +++++++++++++++++++-- evennia/contrib/events/typeclasses.py | 13 ++-- 4 files changed, 165 insertions(+), 17 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 42b032aba..44453f306 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -292,7 +292,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # Open the editor event = self.handler.add_event(obj, event_name, "", - self.caller, False) + self.caller, False, parameters=self.parameters) self.caller.db._event = event EvEditor(self.caller, loadfunc=_ev_load, savefunc=_ev_save, quitfunc=_ev_quit, key="Event {} of {}".format( diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index a0fa28475..e5d86aafc 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -8,8 +8,13 @@ and are designed to be used more by developers to add event types. from textwrap import dedent +from django.conf import settings from evennia import logger from evennia import ScriptDB +from evennia.contrib.custom_gametime import UNITS +from evennia.contrib.custom_gametime import real_seconds_until as custom_rsu +from evennia.utils.create import create_script +from evennia.utils.gametime import real_seconds_until as standard_rsu hooks = [] event_types = [] @@ -24,7 +29,8 @@ def get_event_handler(): return script -def create_event_type(typeclass, event_name, variables, help_text): +def create_event_type(typeclass, event_name, variables, help_text, + custom_add=None): """ Create a new event type for a specific typeclass. @@ -33,6 +39,8 @@ def create_event_type(typeclass, event_name, variables, help_text): event_name (str): the name of the event to be added. variables (list of str): a list of variable names. help_text (str): a help text of the event. + custom_add (function, default None): a callback to call when adding + the new event. Events obey the inheritance hierarchy: if you set an event on DefaultRoom, for instance, and if your Room typeclass inherits @@ -44,7 +52,8 @@ def create_event_type(typeclass, event_name, variables, help_text): """ typeclass_name = typeclass.__module__ + "." + typeclass.__name__ - event_types.append((typeclass_name, event_name, variables, help_text)) + event_types.append((typeclass_name, event_name, variables, help_text, + custom_add)) def del_event_type(typeclass, event_name): """ @@ -118,7 +127,8 @@ def connect_event_types(): "cannot be found.") return - for typeclass_name, event_name, variables, help_text in event_types: + for typeclass_name, event_name, variables, help_text, \ + custom_add in event_types: # Get the event types for this typeclass if typeclass_name not in script.ndb.event_types: script.ndb.event_types[typeclass_name] = {} @@ -126,4 +136,80 @@ def connect_event_types(): # Add or replace the event help_text = dedent(help_text.strip("\n")) - types[event_name] = (variables, help_text) + types[event_name] = (variables, help_text, custom_add) + +# Custom callbacks for specific events +def get_next_wait(format): + """ + Get the length of time in seconds before format. + + Args: + format (str): a time format matching the set calendar. + + 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_err("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)) + i += 1 + + params["sec"] = 0 + details = " ".join(details) + seconds = rsu(**params) + return seconds, details + +def create_time_event(obj, event_name, number, parameters): + """ + Create an time-related event. + + args: + obj (Object): the object on which stands the event. + event_name (str): the event's name. + number (int): the number of the event. + parameter (str): the parameter of the event. + + """ + print "parameters", repr(parameters) + seconds, key = get_next_wait(parameters) + script = create_script("evennia.contrib.events.scripts.TimeEventScript", interval=seconds, obj=obj) + script.key = key + script.desc = "time event called regularly on {}".format(key) + script.db.time_format = parameters + script.db.number = number diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 058dc17fd..3cc18890d 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -5,10 +5,12 @@ Scripts for the event system. from datetime import datetime from Queue import Queue -from evennia import DefaultScript +from django.conf import settings +from evennia import DefaultScript, ScriptDB from evennia import logger +from evennia.contrib.events.custom import connect_event_types, \ + get_next_wait, patch_hooks from evennia.contrib.events.exceptions import InterruptEvent -from evennia.contrib.events.custom import connect_event_types, patch_hooks from evennia.contrib.events import typeclasses from evennia.utils.utils import all_from_module @@ -64,7 +66,8 @@ class EventHandler(DefaultScript): return types - def add_event(self, obj, event_name, code, author=None, valid=False): + def add_event(self, obj, event_name, code, author=None, valid=False, + parameters=""): """ Add the specified event. @@ -74,6 +77,7 @@ class EventHandler(DefaultScript): code (str): the Python code associated with this event. author (optional, Character, Player): the author of the event. valid (optional, bool): should the event be connected? + parameters (str, optional): optional parameters. This method doesn't check that the event type exists. @@ -100,6 +104,13 @@ class EventHandler(DefaultScript): if not valid: self.db.to_valid.append((obj, event_name, len(events) - 1)) + # Call the custom_add if needed + custom_add = self.get_event_types(obj).get( + event_name, [None, None, None])[2] + print "custom_add", custom_add + if custom_add: + custom_add(obj, event_name, len(events) - 1, parameters) + # Build the definition to return (a dictionary) definition = dict(events[-1]) definition["obj"] = obj @@ -163,7 +174,7 @@ class EventHandler(DefaultScript): if (obj, event_name, number) in self.db.to_valid: self.db.to_valid.remove((obj, event_name, number)) - def call_event(self, obj, event_name, *args): + def call_event(self, obj, event_name, number=None, *args): """ Call the event. @@ -171,6 +182,7 @@ class EventHandler(DefaultScript): obj (Object): the Evennia typeclassed object. event_name (str): the event name to call. *args: additional variables for this event. + number (int, default None): call just a specific event. Returns: True to report the event was called without interruption, @@ -198,13 +210,64 @@ class EventHandler(DefaultScript): # Now execute all the valid events linked at this address events = self.db.events.get(obj, {}).get(event_name, []) - for event in events: + for i, event in enumerate(events): if not event["valid"]: continue + if number is not None and i != number: + continue + try: exec(event["code"], locals, locals) except InterruptEvent: return False return True + + +# 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.""" + # Get the event handler and call the script + try: + script = ScriptDB.objects.get(db_key="event_handler") + except ScriptDB.DoesNotExist: + logger.log_err("Can't get the event handler.") + return + + if self.db.event_name and self.db.number is not None: + obj = self.obj + event_name = self.db.event_name + number = self.db.number + events = script.db.events.get(obj, {}).get(event_name) + if events is None: + logger.log_err("Cannot find the event {} on {}".format( + event_name, obj)) + return + + try: + event = events[number] + except IndexError: + logger.log_err("Cannot find the event {} {} on {}".format( + event_name, number, obj)) + return + + script.call_event(obj, event_name, number, obj) + + if self.db.time_format: + seconds, details = get_next_wait(self.db.time_format) + self.restart(interval=seconds) diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index cefa1a6b7..286da1ec9 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -4,7 +4,8 @@ Patched typeclasses for Evennia. from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom from evennia import ScriptDB -from evennia.contrib.events.custom import create_event_type, patch_hook +from evennia.contrib.events.custom import create_event_type, patch_hook, \ + create_time_event from evennia.utils.utils import inherits_from class PatchedExit(object): @@ -31,7 +32,7 @@ class PatchedExit(object): is_character = inherits_from(traversing_object, DefaultCharacter) script = ScriptDB.objects.get(db_key="event_handler") if is_character: - allow = script.call_event(exit, "can_traverse", traversing_object, + allow = script.call_event(exit, "can_traverse", None, traversing_object, exit, exit.location) if not allow: return @@ -40,7 +41,7 @@ class PatchedExit(object): # After traversing if is_character: - script.call_event(exit, "traverse", traversing_object, + script.call_event(exit, "traverse", None, traversing_object, exit, exit.location, exit.destination) @@ -71,7 +72,7 @@ create_event_type(DefaultExit, "traverse", ["character", "exit", """) # Room events -create_event_type(DefaultRoom, "time", ["room", "time"], """ +create_event_type(DefaultRoom, "time", ["room"], """ 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 @@ -87,6 +88,4 @@ create_event_type(DefaultRoom, "time", ["room", "time"], """ Variables you can use in this event: room: the room connected to this event. - time: a string containing the current time. -""") - +""", create_time_event) From d6c9d28d4f39218c2b480be49df6db28724399c8 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Thu, 16 Mar 2017 17:47:24 -0700 Subject: [PATCH 064/133] Add chained events with persistent delays --- evennia/contrib/events/commands.py | 22 +++-- evennia/contrib/events/custom.py | 45 ++++++++- evennia/contrib/events/helpers.py | 33 ++++++- evennia/contrib/events/scripts.py | 146 +++++++++++++++++++++++++---- 4 files changed, 214 insertions(+), 32 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 44453f306..408847777 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -226,13 +226,13 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): else: msg += "\nThis event |rhasn't been|n accepted yet." - msg += "\nEvent code:\n " - msg += "\n ".join([l for l in event["code"].splitlines()]) + msg += "\nEvent code:\n" + msg += "\n".join([l for l in event["code"].splitlines()]) self.msg(msg) return # No parameter has been specified, display the table of events - cols = ["Number", "Author", "Updated"] + cols = ["Number", "Author", "Updated", "Param"] if self.is_validator: cols.append("Valid") @@ -251,25 +251,28 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): (now - updated_on).total_seconds(), 1) else: updated_on = "|gUnknown|n" + parameters = event.get("parameters", "") - row = [str(i + 1), author, updated_on] + row = [str(i + 1), author, updated_on, parameters] if self.is_validator: row.append("Yes" if event.get("valid") else "No") table.add_row(*row) self.msg(table) else: + names = list(set(list(types.keys()) + list(events.keys()))) table = EvTable("Event 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, infos in sorted(types.items()): + for name in sorted(names): number = len(events.get(name, [])) lines = sum(len(e["code"].splitlines()) for e in \ events.get(name, [])) no = "{} ({})".format(number, lines) - description = infos[1].splitlines()[0] + description = types.get(name, (None, "Chained event."))[1] + description = description.splitlines()[0] table.add_row(name, no, description) self.msg(table) @@ -281,12 +284,12 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): types = self.handler.get_event_types(obj) # Check that the event exists - if not event_name in types: + if not event_name.startswith("chain_") and not event_name in types: self.msg("The event name {} can't be found in {} of " \ "typeclass {}.".format(event_name, obj, type(obj))) return - definition = types[event_name] + definition = types.get(event_name, (None, "Chain event")) description = definition[1] self.msg(description) @@ -319,6 +322,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # If there's only one event, just edit it if len(events[event_name]) == 1: + parameters = 0 event = events[event_name][0] else: if not parameters: @@ -343,7 +347,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): return # Check the definition of the event - definition = types[event_name] + definition = types.get(event_name, (None, "Chained event")) description = definition[1] self.msg(description) diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index e5d86aafc..eb47709ea 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -30,7 +30,7 @@ def get_event_handler(): return script def create_event_type(typeclass, event_name, variables, help_text, - custom_add=None): + custom_add=None, custom_call=None): """ Create a new event type for a specific typeclass. @@ -41,6 +41,8 @@ def create_event_type(typeclass, event_name, variables, help_text, help_text (str): a help text of the event. custom_add (function, default None): a callback to call when adding the new event. + custom_xcall (function, default None): a callback to call when + preparing to call the events. Events obey the inheritance hierarchy: if you set an event on DefaultRoom, for instance, and if your Room typeclass inherits @@ -53,7 +55,7 @@ def create_event_type(typeclass, event_name, variables, help_text, """ typeclass_name = typeclass.__module__ + "." + typeclass.__name__ event_types.append((typeclass_name, event_name, variables, help_text, - custom_add)) + custom_add, custom_call)) def del_event_type(typeclass, event_name): """ @@ -127,8 +129,13 @@ def connect_event_types(): "cannot be found.") return - for typeclass_name, event_name, variables, help_text, \ - custom_add in event_types: + if script.ndb.event_types is None: + return + + while event_types: + typeclass_name, event_name, variables, help_text, \ + custom_add, custom_call = event_types[0] + # Get the event types for this typeclass if typeclass_name not in script.ndb.event_types: script.ndb.event_types[typeclass_name] = {} @@ -136,7 +143,8 @@ def connect_event_types(): # Add or replace the event help_text = dedent(help_text.strip("\n")) - types[event_name] = (variables, help_text, custom_add) + types[event_name] = (variables, help_text, custom_add, custom_call) + del event_types[0] # Custom callbacks for specific events def get_next_wait(format): @@ -213,3 +221,30 @@ def create_time_event(obj, event_name, number, parameters): script.desc = "time event called regularly on {}".format(key) script.db.time_format = parameters script.db.number = number + +def keyword_event(events, parameters): + """ + Custom call for events with keywords (like say, or push, or pull, or turn...). + + This function should be imported and added as a custom_call + parameter to add the event type 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' event can + be triggered to trigger when the player 'push 1' or 'push one'. + + Args: + events (list of dict): the list of events to be called. + parameters (str): the actual parameters entered to trigger the event. + + Returns: + A list containing the event dictionaries to be called. + + """ + key = parameters.strip().lower() + to_call = [] + for event in events: + keys = event["parameters"] + if not keys or key in [p.strip().lower() for p in keys.split(",")]: + to_call.append(event) + + return to_call diff --git a/evennia/contrib/events/helpers.py b/evennia/contrib/events/helpers.py index b763d5781..5d4badde7 100644 --- a/evennia/contrib/events/helpers.py +++ b/evennia/contrib/events/helpers.py @@ -6,7 +6,7 @@ Hlpers are just Python function that can be used inside of events. They """ -from evennia import ObjectDB +from evennia import ObjectDB, ScriptDB from evennia.contrib.events.exceptions import InterruptEvent def deny(): @@ -51,3 +51,34 @@ def get(**kwargs): object = None return object + +def call(obj, event_name, seconds=0): + """ + Call the specified event in X seconds. + + This helper 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. + + 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. + + Notice that chained events are designed for this very purpose: they + are never called automatically by the game, rather, they need to be + called from inside another event. + + """ + try: + script = ScriptDB.objects.get(db_key="event_handler") + except ScriptDB.DoesNotExist: + return + + # Schedule the task + script.set_task(seconds, obj, event_name) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 3cc18890d..e653c4d30 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -2,7 +2,7 @@ Scripts for the event system. """ -from datetime import datetime +from datetime import datetime, timedelta from Queue import Queue from django.conf import settings @@ -12,7 +12,8 @@ from evennia.contrib.events.custom import connect_event_types, \ get_next_wait, patch_hooks from evennia.contrib.events.exceptions import InterruptEvent from evennia.contrib.events import typeclasses -from evennia.utils.utils import all_from_module +from evennia.utils.dbserialize import dbserialize +from evennia.utils.utils import all_from_module, delay class EventHandler(DefaultScript): @@ -27,12 +28,34 @@ class EventHandler(DefaultScript): self.db.events = {} self.db.to_valid = [] + # Tasks + self.db.task_id = 0 + self.db.tasks = {} + def at_start(self): """Set up the event system.""" self.ndb.event_types = {} connect_event_types() patch_hooks() + # Generate locals + self.ndb.current_locals = {} + addresses = ["evennia.contrib.events.helpers"] + self.ndb.fresh_locals = {} + for address in addresses: + 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) + + def get_events(self, obj): """ Return a dictionary of the object's events. @@ -98,6 +121,7 @@ class EventHandler(DefaultScript): "author": author, "valid": valid, "code": code, + "parameters": parameters, }) # If not valid, set it in 'to_valid' @@ -107,7 +131,6 @@ class EventHandler(DefaultScript): # Call the custom_add if needed custom_add = self.get_event_types(obj).get( event_name, [None, None, None])[2] - print "custom_add", custom_add if custom_add: custom_add(obj, event_name, len(events) - 1, parameters) @@ -174,7 +197,7 @@ class EventHandler(DefaultScript): if (obj, event_name, number) in self.db.to_valid: self.db.to_valid.remove((obj, event_name, number)) - def call_event(self, obj, event_name, number=None, *args): + def call_event(self, obj, event_name, *args, **kwargs): """ Call the event. @@ -182,7 +205,11 @@ class EventHandler(DefaultScript): obj (Object): the Evennia typeclassed object. event_name (str): the event name to call. *args: additional variables for this event. + + Kwargs: number (int, default None): call just a specific event. + parameters (str, default ""): call an event with parameters. + locals (dict): a locals replacement. Returns: True to report the event was called without interruption, @@ -190,26 +217,46 @@ class EventHandler(DefaultScript): """ # First, look for the event type corresponding to this name - # To do so, go back the inheritance tree + 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 events: {}".format(kwargs)) + event_type = self.get_event_types(obj).get(event_name) - if not event_type: + if locals is None and not event_type: logger.log_err("The event {} for the object {} (typeclass " \ "{}) can't be found".format(event_name, obj, type(obj))) return False - # Prepare the locals - locals = all_from_module("evennia.contrib.events.helpers") - for i, variable in enumerate(event_type[0]): - try: - locals[variable] = args[i] - except IndexError: - logger.log_err("event {} of {} ({}): need variable " \ - "{} in position {}".format(event_name, obj, - type(obj), variable, i)) - return False + # Prepare the locals if necessary + if locals is None: + locals = self.ndb.fresh_locals.copy() + for i, variable in enumerate(event_type[0]): + try: + locals[variable] = args[i] + except IndexError: + logger.log_err("event {} of {} ({}): need variable " \ + "{} in position {}".format(event_name, obj, + type(obj), variable, i)) + return False + else: + locals = {key: value for key, value in locals.items()} + + events = self.db.events.get(obj, {}).get(event_name, []) + + # Filter down of events if there is a custom call + if event_type: + custom_call = event_type[3] + if custom_call: + events = custom_call(events, parameters) # Now execute all the valid events linked at this address - events = self.db.events.get(obj, {}).get(event_name, []) + self.ndb.current_locals = locals for i, event in enumerate(events): if not event["valid"]: continue @@ -224,6 +271,45 @@ class EventHandler(DefaultScript): return True + def set_task(self, seconds, obj, event_name): + """ + Set and schedule a task to run. + + 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. + + Args: + seconds (int/float): the delay in seconds from now. + obj (Object): the typecalssed object connected to the event. + event_name (str): the event's name. + + Note that 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) + task_id = self.db.task_id + self.db.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, event_name, locals) + delay(seconds, complete_task, task_id) + # Script to call time-related events class TimeEventScript(DefaultScript): @@ -271,3 +357,29 @@ class TimeEventScript(DefaultScript): if self.db.time_format: seconds, details = get_next_wait(self.db.time_format) self.restart(interval=seconds) + + +# Functions to manipulate tasks +def complete_task(task_id): + """ + Mark the task in the event handler as complete. + + This function should be called automatically for individual tasks. + + Args: + task_id (int): the task id. + + """ + try: + script = ScriptDB.objects.get(db_key="event_handler") + except ScriptDB.DoesNotExist: + logger.log_err("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, event_name, locals = script.db.tasks.pop(task_id) + script.call_event(obj, event_name, locals=locals) From 8dc5c69d0088009adf2c192c601d4fd101ce8273 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Thu, 16 Mar 2017 20:37:51 -0700 Subject: [PATCH 065/133] Fix a bug in time-related events --- evennia/contrib/events/custom.py | 1 - evennia/contrib/events/scripts.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index eb47709ea..46f95aab9 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -214,7 +214,6 @@ def create_time_event(obj, event_name, number, parameters): parameter (str): the parameter of the event. """ - print "parameters", repr(parameters) seconds, key = get_next_wait(parameters) script = create_script("evennia.contrib.events.scripts.TimeEventScript", interval=seconds, obj=obj) script.key = key diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index e653c4d30..38df34f4f 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -352,7 +352,7 @@ class TimeEventScript(DefaultScript): event_name, number, obj)) return - script.call_event(obj, event_name, number, obj) + script.call_event(obj, event_name, obj, number=number) if self.db.time_format: seconds, details = get_next_wait(self.db.time_format) From ccdb56c139e6d6405fda782d35cf391eccaa4e2a Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sat, 18 Mar 2017 16:52:29 -0700 Subject: [PATCH 066/133] Add the @event/del switch to delete events --- evennia/contrib/events/commands.py | 48 +++++++++++++++++++++++++++- evennia/contrib/events/custom.py | 2 +- evennia/contrib/events/scripts.py | 51 ++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 408847777..e848baff7 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -364,7 +364,53 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): def del_event(self): """Delete an event.""" obj = self.obj - self.msg("Calling del.") + event_name = self.event_name + parameters = self.parameters + events = self.handler.get_events(obj) + types = self.handler.get_event_types(obj) + + # If no event name is specified, display the list of events + if not event_name: + self.list_events() + return + + # Check that the event exists + if not event_name in events: + self.msg("The event name {} can't be found in {}.".format( + event_name, obj)) + return + + # If there's only one event, just delete it + if len(events[event_name]) == 1: + parameters = 0 + event = events[event_name][0] + else: + if not parameters: + self.msg("Which event do you wish to delete? Specify " \ + "a number.") + self.list_events() + return + + # Check that the parameter points to an existing event + try: + parameters = int(parameters) - 1 + assert parameters >= 0 + event = events[event_name][parameters] + except (AssertionError, ValueError): + self.msg("The event {} {} cannot be found in {}.".format( + event_name, parameters, obj)) + return + + # If caller can't edit without validation, forbid deleting + # others' works + if not self.autovalid and event["author"] is not self.caller: + self.msg("You cannot delete this event created by someone else.") + return + + # Delete the event + self.handler.del_event(obj, event_name, parameters) + self.msg("The event {} {} of {} was deleted.".format( + obj, event_name, parameters + 1)) def accept_event(self): """Accept an event.""" diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index 46f95aab9..8c0c41a37 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -217,7 +217,7 @@ def create_time_event(obj, event_name, number, parameters): seconds, key = get_next_wait(parameters) script = create_script("evennia.contrib.events.scripts.TimeEventScript", interval=seconds, obj=obj) script.key = key - script.desc = "time event called regularly on {}".format(key) + script.desc = "event on {}".format(key) script.db.time_format = parameters script.db.number = number diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 38df34f4f..557bf02ff 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -178,6 +178,57 @@ class EventHandler(DefaultScript): # If not valid, set it in 'to_valid' if not valid and (obj, event_name, number) not in self.db.to_valid: self.db.to_valid.append((obj, event_name, number)) + elif valid and (obj, event_name, number) in self.db.to_valid: + self.db.to_valid.remove((obj, event_name, number)) + + + def del_event(self, obj, event_name, number): + """ + Delete the specified event. + + Args: + obj (Object): the typeclassed object containing the event. + event_name (str): the name of the event to delete. + number (int): the number of the event to delete. + + """ + obj_events = self.db.events.get(obj, {}) + events = obj_events.get(event_name, []) + + # Delete the event itself + try: + code = events[number]["code"] + except IndexError: + return + else: + logger.log_info("Deleting event {} {} of {}:\n{}".format( + event_name, number, obj, code)) + del events[number] + + # Change IDs of events to be validated + i = 0 + while i < len(self.db.to_valid): + t_obj, t_event_name, t_number = self.db.to_valid[i] + if obj is t_obj and event_name == t_event_name: + if t_number == number: + # Strictly equal, delete the event + del self.db.to_valid[i] + i -= 1 + elif t_number > number: + # Change the ID for this event + self.db.to_valid.insert(i, (t_obj, t_event_name, + t_number - 1)) + del self.db.to_valid[i + 1] + i += 1 + + # Delete time-related events associated with this object + for script in list(obj.scripts.all()): + if isinstance(script, TimeEventScript): + if script.obj is obj and script.db.event_name == event_name: + if script.db.number == number: + script.stop() + elif script.db.number > number: + script.db.number -= 1 def accept_event(self, obj, event_name, number): """ From 9fc163df5741721d8f7dfc130eedb6dd24004674 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sat, 18 Mar 2017 17:28:02 -0700 Subject: [PATCH 067/133] Add a lock on editing events --- evennia/contrib/events/commands.py | 15 +++++++++++++++ evennia/contrib/events/scripts.py | 16 ++++++++++++++++ evennia/contrib/events/typeclasses.py | 4 ++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index e848baff7..1abdb9e5b 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -346,6 +346,12 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): self.msg("You cannot edit this event created by someone else.") return + # If the event is locked (edited by someone else) + if (obj, event_name, parameters) in self.handler.db.locked: + self.msg("This event is locked, you cannot edit it.") + return + self.handler.db.locked.append((obj, event_name, parameters)) + # Check the definition of the event definition = types.get(event_name, (None, "Chained event")) description = definition[1] @@ -407,6 +413,11 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): self.msg("You cannot delete this event created by someone else.") return + # If the event is locked (edited by someone else) + if (obj, event_name, parameters) in self.handler.db.locked: + self.msg("This event is locked, you cannot delete it.") + return + # Delete the event self.handler.del_event(obj, event_name, parameters) self.msg("The event {} {} of {} was deleted.".format( @@ -505,6 +516,10 @@ def _ev_save(caller, buf): caller.msg("Couldn't save this event.") return False + if (event["obj"], event["name"], event["number"]) in handler.db.locked: + handler.db.locked.remove((event["obj"], event["name"], + event["number"])) + handler.edit_event(event["obj"], event["name"], event["number"], buf, caller, valid=autovalid) return True diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 557bf02ff..d271a0184 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -27,6 +27,7 @@ class EventHandler(DefaultScript): # Permanent data to be stored self.db.events = {} self.db.to_valid = [] + self.db.locked = [] # Tasks self.db.task_id = 0 @@ -167,6 +168,10 @@ class EventHandler(DefaultScript): obj_events[event_name] = [] events = obj_events[event_name] + # If locked, don't edit it + if (obj, event_name, number) in self.db.locked: + raise RunTimeError("this event is locked.") + # Edit the event events[number].update({ "updated_on": datetime.now(), @@ -195,6 +200,10 @@ class EventHandler(DefaultScript): obj_events = self.db.events.get(obj, {}) events = obj_events.get(event_name, []) + # If locked, don't edit it + if (obj, event_name, number) in self.db.locked: + raise RunTimeError("this event is locked.") + # Delete the event itself try: code = events[number]["code"] @@ -221,6 +230,13 @@ class EventHandler(DefaultScript): del self.db.to_valid[i + 1] i += 1 + # Update locked event + for line in self.db.locked: + t_obj, t_event_name, t_number = line + if obj is t_obj and event_name == t_event_name: + if number > t_number: + line[2] -= 1 + # Delete time-related events associated with this object for script in list(obj.scripts.all()): if isinstance(script, TimeEventScript): diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 286da1ec9..ebe55cb33 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -32,7 +32,7 @@ class PatchedExit(object): is_character = inherits_from(traversing_object, DefaultCharacter) script = ScriptDB.objects.get(db_key="event_handler") if is_character: - allow = script.call_event(exit, "can_traverse", None, traversing_object, + allow = script.call_event(exit, "can_traverse", traversing_object, exit, exit.location) if not allow: return @@ -41,7 +41,7 @@ class PatchedExit(object): # After traversing if is_character: - script.call_event(exit, "traverse", None, traversing_object, + script.call_event(exit, "traverse", traversing_object, exit, exit.location, exit.destination) From 629ac73f2b096871c2ea4f60e917ee441084b7eb Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sat, 18 Mar 2017 18:15:26 -0700 Subject: [PATCH 068/133] Add the @event/tasks switch to follow differed tasks --- evennia/contrib/events/commands.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 1abdb9e5b..7ec59983e 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -28,12 +28,14 @@ BASIC_USAGES = [ "@event/add object name = event name [parameters]", "@event/edit object name = event name [event number]", "@event/del object name = event name [event number]", + "@event/tasks [object name [= event name [event number]]]", ] BASIC_SWITCHES = [ "add - add and edit a new event", "edit - edit an existing event", "del - delete an existing event", + "tasks - show the list of differed tasks", ] VALIDATOR_USAGES = [ @@ -52,7 +54,11 @@ switches to see what event are active on an object: You can also specify an event name if you want the list of events associated with this object of this name: @event north = can_traverse +You might need to specify a number after the event if there are more than one: + @event here = say 2 You can also add, edit or remove events using the add, edit or del switches. +Additionally, you can see the list of differed tasks created by events +(chained events to be called) using the /tasks switch. """ VALIDATOR_TEXT = """ @@ -174,6 +180,8 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): self.del_event() elif switch == "accept" and validator: self.accept_event() + elif switch in ["tasks", "task"]: + self.list_tasks() else: caller.msg("Mutually exclusive or invalid switches were " \ "used, cannot proceed.") @@ -500,6 +508,28 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): self.msg("The event {} {} of {} has been accepted.".format( event_name, parameters, obj)) + def list_tasks(self): + """List the active tasks.""" + obj = self.obj + event_name = self.event_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 event_name: + tasks = [task for task in tasks if task[3] == event_name] + + tasks.sort() + table = EvTable("ID", "Object", "Event", "In", width=78) + table.reformat_column(0, align="r") + now = datetime.now() + for task_id, future, obj, event_name in tasks: + key = obj.get_display_name(self.caller) + delta = time_format((future - now).total_seconds(), 1) + table.add_row(task_id, key, event_name, delta) + + self.msg(table) + # Private functions to handle editing def _ev_load(caller): return caller.db._event and caller.db._event.get("code", "") or "" From 81f4c590bddb95da708490b9bbc8f79e763c78c9 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 20 Mar 2017 12:29:04 -0700 Subject: [PATCH 069/133] Bring the event system to Evennia-style compliance --- evennia/contrib/events/commands.py | 157 ++++++++++++-------------- evennia/contrib/events/custom.py | 68 ++++------- evennia/contrib/events/helpers.py | 10 +- evennia/contrib/events/scripts.py | 85 +++++++++----- evennia/contrib/events/typeclasses.py | 25 ++-- 5 files changed, 174 insertions(+), 171 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 7ec59983e..9335af5b1 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -6,12 +6,12 @@ from datetime import datetime from django.conf import settings from evennia import Command -from evennia.contrib.events.custom import get_event_handler +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.custom import get_event_handler -COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) # Permissions @@ -20,26 +20,26 @@ WITHOUT_VALIDATION = getattr(settings, "EVENTS_WITHOUT_VALIDATION", "immortals") VALIDATING = getattr(settings, "EVENTS_VALIDATING", "immortals") -# Split help file +# Split help text BASIC_HELP = "Add, edit or delete events." BASIC_USAGES = [ - "@event object name [= event name]", - "@event/add object name = event name [parameters]", - "@event/edit object name = event name [event number]", - "@event/del object name = event name [event number]", - "@event/tasks [object name [= event name [event number]]]", + "@event [= ]", + "@event/add = [parameters]", + "@event/edit = [event number]", + "@event/del = [event number]", + "@event/tasks [object name [= ]]", ] BASIC_SWITCHES = [ - "add - add and edit a new event", - "edit - edit an existing event", - "del - delete an existing event", - "tasks - show the list of differed tasks", + "add - add and edit a new event", + "edit - edit an existing event", + "del - delete an existing event", + "tasks - show the list of differed tasks", ] VALIDATOR_USAGES = [ - "@event/accept [object name = event name [event number]]", + "@event/accept [object name = [event number]]", ] VALIDATOR_SWITCHES = [ @@ -50,12 +50,12 @@ BASIC_TEXT = """ This command is used to manipulate events. An event can be linked to an object, to fire at a specific moment. You can use the command without switches to see what event are active on an object: - @event self + @event self You can also specify an event name if you want the list of events associated with this object of this name: - @event north = can_traverse -You might need to specify a number after the event if there are more than one: - @event here = say 2 + @event north = can_traverse +You can also add a number after the event name to see details on one event: + @event here = say 2 You can also add, edit or remove events using the add, edit or del switches. Additionally, you can see the list of differed tasks created by events (chained events to be called) using the /tasks switch. @@ -66,27 +66,26 @@ You can also use this command to validate events. Depending on your game setting, some users might be allowed to add new events, but these events will not be fired until you accept them. To see the events needing validation, enter the /accept switch without argument: - @event/accept + @event/accept A table will show you the events that are not validated yet, who created -it and when. You can then accept a specific event: - @event here = enter -Or, if more than one events are connected here, specify the number: - @event here = enter 3 +them and when. You can then accept a specific event: + @event here = enter 1 Use the /del switch to remove events that should not be connected. """ class CmdEvent(COMMAND_DEFAULT_CLASS): - """Command to edit events.""" + """ + Command to edit events. + """ key = "@event" - locks = "cmd:perm({})".format(VALIDATING) aliases = ["@events", "@ev"] + 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. @@ -104,18 +103,18 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): """ lock = "perm({}) or perm(events_validating)".format(VALIDATING) validator = caller.locks.check_lockstring(caller, lock) - text = "\n" + BASIC_HELP + "\n\nUsages:\n " + text = "\n" + BASIC_HELP + "\n\nUsages:\n " # Usages - text += "\n ".join(BASIC_USAGES) + text += "\n ".join(BASIC_USAGES) if validator: - text += "\n " + "\n ".join(VALIDATOR_USAGES) + text += "\n " + "\n ".join(VALIDATOR_USAGES) # Switches - text += "\n\nSwitches:\n " - text += "\n ".join(BASIC_SWITCHES) + text += "\n\nSwitches:\n " + text += "\n ".join(BASIC_SWITCHES) if validator: - text += "\n " + "\n".join(VALIDATOR_SWITCHES) + text += "\n " + "\n ".join(VALIDATOR_SWITCHES) # Text text += "\n" + BASIC_TEXT @@ -146,37 +145,25 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): "access the event system.") return - # Before the equal sign is always an object name - if self.args.strip(): + # 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 == "": - if not self.obj: - caller.msg("Specify an object's name or #ID.") - return + 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_events() elif switch == "add": - if not self.obj: - caller.msg("Specify an object's name or #ID.") - return - self.add_event() elif switch == "edit": - if not self.obj: - caller.msg("Specify an object's name or #ID.") - return - self.edit_event() elif switch == "del": - if not self.obj: - caller.msg("Specify an object's name or #ID.") - return - self.del_event() elif switch == "accept" and validator: self.accept_event() @@ -198,16 +185,17 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # Check that the event name can be found in this object created = events.get(event_name) if created is None: - self.msg("No event {} has been set on {}.".format(event_name, obj)) + self.msg("No event {} has been set on {}.".format(event_name, + obj)) return if parameters: # Check that the parameter points to an existing event try: - parameters = int(parameters) - 1 - assert parameters >= 0 - event = events[event_name][parameters] - except (AssertionError, ValueError): + number = int(parameters) - 1 + assert number >= 0 + event = events[event_name][number] + except (ValueError, AssertionError, IndexError): self.msg("The event {} {} cannot be found in {}.".format( event_name, parameters, obj)) return @@ -223,10 +211,9 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): updated_on = event.get("updated_on") updated_on = updated_on.strftime("%Y-%m-%d %H:%M:%S") \ if updated_on else "|gUnknown|n" - number = parameters + 1 - msg = "Event {} {} of {}:".format(event_name, number, obj) - msg += "\nCreated by {} at {}.".format(author, created_on) - msg += "\nUpdated by {} at {}".format(updated_by, updated_on) + msg = "Event {} {} of {}:".format(event_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 event.get("valid"): @@ -235,7 +222,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): msg += "\nThis event |rhasn't been|n accepted yet." msg += "\nEvent code:\n" - msg += "\n".join([l for l in event["code"].splitlines()]) + msg += raw(event["code"]) self.msg(msg) return @@ -255,8 +242,9 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): updated_on = event.get("created_on") if updated_on: - updated_on = time_format( - (now - updated_on).total_seconds(), 1) + updated_on = "{} ago".format(time_format( + (now - updated_on).total_seconds(), + 4).capitalize()) else: updated_on = "|gUnknown|n" parameters = event.get("parameters", "") @@ -330,7 +318,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # If there's only one event, just edit it if len(events[event_name]) == 1: - parameters = 0 + number = 0 event = events[event_name][0] else: if not parameters: @@ -340,10 +328,10 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # Check that the parameter points to an existing event try: - parameters = int(parameters) - 1 - assert parameters >= 0 - event = events[event_name][parameters] - except (AssertionError, ValueError): + number = int(parameters) - 1 + assert number >= 0 + event = events[event_name][number] + except (ValueError, AssertionError, IndexError): self.msg("The event {} {} cannot be found in {}.".format( event_name, parameters, obj)) return @@ -355,10 +343,10 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): return # If the event is locked (edited by someone else) - if (obj, event_name, parameters) in self.handler.db.locked: + if (obj, event_name, number) in self.handler.db.locked: self.msg("This event is locked, you cannot edit it.") return - self.handler.db.locked.append((obj, event_name, parameters)) + self.handler.db.locked.append((obj, event_name, number)) # Check the definition of the event definition = types.get(event_name, (None, "Chained event")) @@ -369,7 +357,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): event = dict(event) event["obj"] = obj event["name"] = event_name - event["number"] = parameters + event["number"] = number self.caller.db._event = event EvEditor(self.caller, loadfunc=_ev_load, savefunc=_ev_save, quitfunc=_ev_quit, key="Event {} of {}".format( @@ -396,7 +384,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # If there's only one event, just delete it if len(events[event_name]) == 1: - parameters = 0 + number = 0 event = events[event_name][0] else: if not parameters: @@ -407,10 +395,10 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # Check that the parameter points to an existing event try: - parameters = int(parameters) - 1 - assert parameters >= 0 - event = events[event_name][parameters] - except (AssertionError, ValueError): + number = int(parameters) - 1 + assert number >= 0 + event = events[event_name][number] + except (ValueError, AssertionError, IndexError): self.msg("The event {} {} cannot be found in {}.".format( event_name, parameters, obj)) return @@ -422,14 +410,14 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): return # If the event is locked (edited by someone else) - if (obj, event_name, parameters) in self.handler.db.locked: + if (obj, event_name, number) in self.handler.db.locked: self.msg("This event is locked, you cannot delete it.") return # Delete the event - self.handler.del_event(obj, event_name, parameters) + self.handler.del_event(obj, event_name, number) self.msg("The event {} {} of {} was deleted.".format( - obj, event_name, parameters + 1)) + obj, event_name, parameters)) def accept_event(self): """Accept an event.""" @@ -461,8 +449,9 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): updated_on = event.get("created_on") if updated_on: - updated_on = time_format( - (now - updated_on).total_seconds(), 1) + updated_on = "{} ago".format(time_format( + (now - updated_on).total_seconds(), + 4).capitalize()) else: updated_on = "|gUnknown|n" @@ -492,10 +481,10 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # Check that the parameter points to an existing event try: - parameters = int(parameters) - 1 - assert parameters >= 0 - event = events[event_name][parameters] - except (AssertionError, ValueError): + number = int(parameters) - 1 + assert number >= 0 + event = events[event_name][number] + except (ValueError, AssertionError, IndexError): self.msg("The event {} {} cannot be found in {}.".format( event_name, parameters, obj)) return @@ -504,7 +493,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): if event["valid"]: self.msg("This event has already been accepted.") else: - self.handler.accept_event(obj, event_name, parameters) + self.handler.accept_event(obj, event_name, number) self.msg("The event {} {} of {} has been accepted.".format( event_name, parameters, obj)) diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index 8c0c41a37..7c35c5b2e 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -11,10 +11,10 @@ from textwrap import dedent from django.conf import settings from evennia import logger from evennia import ScriptDB -from evennia.contrib.custom_gametime import UNITS -from evennia.contrib.custom_gametime import real_seconds_until as custom_rsu from evennia.utils.create import create_script from evennia.utils.gametime import real_seconds_until as standard_rsu +from evennia.contrib.custom_gametime import UNITS +from evennia.contrib.custom_gametime import real_seconds_until as custom_rsu hooks = [] event_types = [] @@ -24,7 +24,7 @@ def get_event_handler(): try: script = ScriptDB.objects.get(db_key="event_handler") except ScriptDB.DoesNotExist: - logger.log_err("Can't get the event handler.") + logger.log_trace("Can't get the event handler.") script = None return script @@ -39,10 +39,10 @@ def create_event_type(typeclass, event_name, variables, help_text, event_name (str): the name of the event to be added. variables (list of str): a list of variable names. help_text (str): a help text of the event. - custom_add (function, default None): a callback to call when adding + custom_add (function, optional): a callback to call when adding the new event. - custom_xcall (function, default None): a callback to call when - preparing to call the events. + custom_call (function, optional): a callback to call when + preparing to call the event. Events obey the inheritance hierarchy: if you set an event on DefaultRoom, for instance, and if your Room typeclass inherits @@ -50,42 +50,23 @@ def create_event_type(typeclass, event_name, variables, help_text, all rooms. Objects of the typeclass set in argument will be able to set one or more events of that name. - If the event already exists in the typeclass, replace it. + If the event type already exists in the typeclass, replace it. """ typeclass_name = typeclass.__module__ + "." + typeclass.__name__ event_types.append((typeclass_name, event_name, variables, help_text, custom_add, custom_call)) -def del_event_type(typeclass, event_name): - """ - Delete the event type for this typeclass. - - Args: - typeclass (type): the class defining the typeclass. - event_name (str): the name of the event to be deleted. - - If you want to delete an event type, you need to remove it from - the typeclass that defined it: other typeclasses in the inheritance - hierarchy are not affected. This method doesn't remove the - already-created events associated with individual objects. - - """ - typeclass_name = typeclass.__module__ + "." + typeclass.__name__ - try: - script = ScriptDB.objects.get(db_key="event_handler") - except ScriptDB.DoesNotExist: - logger.log_err("Can't create event {} in typeclass {}, the " \ - "script handler isn't defined".format(name, typeclass_name)) - return - - # Get the event types for this typeclass - event_types = script.ndb.event_types.get(typeclass_name, {}) - if event_name in event_types: - del event_types[event_name] - def patch_hook(typeclass, method_name): - """Decorator to softly patch a hook in a typeclass.""" + """ + Decorator to softly patch a hook in a typeclass. + + This decorator should not be used, unless for good reasons, outside + of this contrib. The advantage of using decorated soft patchs is + in allowing users to customize typeclasses without changing the + inheritance tree for a couple of methods. + + """ hook = getattr(typeclass, method_name) def wrapper(method): """Wrapper around the hook.""" @@ -119,13 +100,14 @@ def connect_event_types(): Connect the event types when the script runs. This method should be called automatically by the event handler - (the script). + (the script). It might be useful, however, to call it after adding + new event types in typeclasses. """ try: script = ScriptDB.objects.get(db_key="event_handler") except ScriptDB.DoesNotExist: - logger.log_err("Can't connect event types, the event handler " \ + logger.log_trace("Can't connect event types, the event handler " \ "cannot be found.") return @@ -146,7 +128,7 @@ def connect_event_types(): types[event_name] = (variables, help_text, custom_add, custom_call) del event_types[0] -# Custom callbacks for specific events +# Custom callbacks for specific event types def get_next_wait(format): """ Get the length of time in seconds before format. @@ -188,7 +170,7 @@ def get_next_wait(format): break if not piece.isdigit(): - logger.log_err("The time specified '{}' in {} isn't " \ + logger.log_trace("The time specified '{}' in {} isn't " \ "a valid number".format(piece, format)) return @@ -205,13 +187,13 @@ def get_next_wait(format): def create_time_event(obj, event_name, number, parameters): """ - Create an time-related event. + Create a time-related event. - args: + Args: obj (Object): the object on which stands the event. event_name (str): the event's name. number (int): the number of the event. - parameter (str): the parameter of the event. + parameters (str): the parameter of the event. """ seconds, key = get_next_wait(parameters) @@ -229,7 +211,7 @@ def keyword_event(events, parameters): parameter to add the event type 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' event can - be triggered to trigger when the player 'push 1' or 'push one'. + be set to trigger when the player 'push 1' or 'push one'. Args: events (list of dict): the list of events to be called. diff --git a/evennia/contrib/events/helpers.py b/evennia/contrib/events/helpers.py index 5d4badde7..6675e3ded 100644 --- a/evennia/contrib/events/helpers.py +++ b/evennia/contrib/events/helpers.py @@ -1,8 +1,7 @@ """ Module defining basic helpers for the event system. - -Hlpers are just Python function that can be used inside of events. They +Hlpers are just Python functions that can be used inside of events. They """ @@ -70,9 +69,10 @@ def call(obj, event_name, seconds=0): seconds (int or float): the number of seconds to wait before calling the event. - Notice that chained events are designed for this very purpose: they - are never called automatically by the game, rather, they need to be - called from inside another event. + Note: + Chained events are designed for this very purpose: they + are never called automatically by the game, rather, they need + to be called from inside another event. """ try: diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index d271a0184..d0abdc1dc 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -8,16 +8,24 @@ from Queue import Queue from django.conf import settings from evennia import DefaultScript, ScriptDB from evennia import logger +from evennia.utils.dbserialize import dbserialize +from evennia.utils.utils import all_from_module, delay from evennia.contrib.events.custom import connect_event_types, \ get_next_wait, patch_hooks from evennia.contrib.events.exceptions import InterruptEvent from evennia.contrib.events import typeclasses -from evennia.utils.dbserialize import dbserialize -from evennia.utils.utils import all_from_module, delay class EventHandler(DefaultScript): - """Event handler that contains all events in a global script.""" + """ + The event handler that contains all events in a global script. + + This script shouldn't be created more than once. It contains + event types (in a non-persistent attribute) and events (in a + persistent attribute). The script method would help adding, + editing and deleting these events. + + """ def at_script_creation(self): self.key = "event_handler" @@ -41,8 +49,9 @@ class EventHandler(DefaultScript): # Generate locals self.ndb.current_locals = {} - addresses = ["evennia.contrib.events.helpers"] self.ndb.fresh_locals = {} + addresses = ["evennia.contrib.events.helpers"] + addresses.extend(getattr(settings, "EVENTS_HELPERS_LOCATIONS", [])) for address in addresses: self.ndb.fresh_locals.update(all_from_module(address)) @@ -56,7 +65,6 @@ class EventHandler(DefaultScript): delay(seconds, complete_task, task_id) - def get_events(self, obj): """ Return a dictionary of the object's events. @@ -64,6 +72,13 @@ class EventHandler(DefaultScript): Args: obj (Object): the connected objects. + Returns: + A dictionary of the object's events. + + Note: + This method can be useful to override in some contexts, + when several objects would share events. + """ return self.db.events.get(obj, {}) @@ -74,6 +89,14 @@ class EventHandler(DefaultScript): Args: obj (Object): the connected object. + Returns: + A dictionary of the object's event types. + + Note: + Event types would define what the object can have as + events. Note, however, that chained events will not + appear in event types and are handled separately. + """ types = {} event_types = self.ndb.event_types @@ -96,11 +119,11 @@ class EventHandler(DefaultScript): Add the specified event. Args: - obj (Object): the Evennia typeclassed object to be modified. + obj (Object): the Evennia typeclassed object to be extended. event_name (str): the name of the event to add. code (str): the Python code associated with this event. - author (optional, Character, Player): the author of the event. - valid (optional, bool): should the event be connected? + author (Character or Player, optional): the author of the event. + valid (bool, optional): should the event be connected? parameters (str, optional): optional parameters. This method doesn't check that the event type exists. @@ -148,12 +171,15 @@ class EventHandler(DefaultScript): Edit the specified event. Args: - obj (Object): the Evennia typeclassed object to be modified. - event_name (str): the name of the event to add. + obj (Object): the Evennia typeclassed object to be edited. + event_name (str): the name of the event to edit. number (int): the event number to be changed. code (str): the Python code associated with this event. - author (optional, Character, Player): the author of the event. - valid (optional, bool): should the event be connected? + author (Character or Player, optional): the author of the event. + valid (bool, optional): should the event be connected? + + Raises: + RuntimeError if the event is locked. This method doesn't check that the event type exists. @@ -170,7 +196,7 @@ class EventHandler(DefaultScript): # If locked, don't edit it if (obj, event_name, number) in self.db.locked: - raise RunTimeError("this event is locked.") + raise RuntimeError("this event is locked.") # Edit the event events[number].update({ @@ -186,7 +212,6 @@ class EventHandler(DefaultScript): elif valid and (obj, event_name, number) in self.db.to_valid: self.db.to_valid.remove((obj, event_name, number)) - def del_event(self, obj, event_name, number): """ Delete the specified event. @@ -196,13 +221,16 @@ class EventHandler(DefaultScript): event_name (str): the name of the event to delete. number (int): the number of the event to delete. + Raises: + RuntimeError if the event is locked. + """ obj_events = self.db.events.get(obj, {}) events = obj_events.get(event_name, []) # If locked, don't edit it if (obj, event_name, number) in self.db.locked: - raise RunTimeError("this event is locked.") + raise RuntimeError("this event is locked.") # Delete the event itself try: @@ -274,9 +302,9 @@ class EventHandler(DefaultScript): *args: additional variables for this event. Kwargs: - number (int, default None): call just a specific event. - parameters (str, default ""): call an event with parameters. - locals (dict): a locals replacement. + number (int, optional): call just a specific event. + parameters (str, optional): call an event with parameters. + locals (dict, optional): a locals replacement. Returns: True to report the event was called without interruption, @@ -307,7 +335,7 @@ class EventHandler(DefaultScript): try: locals[variable] = args[i] except IndexError: - logger.log_err("event {} of {} ({}): need variable " \ + logger.log_trace("event {} of {} ({}): need variable " \ "{} in position {}".format(event_name, obj, type(obj), variable, i)) return False @@ -348,15 +376,16 @@ class EventHandler(DefaultScript): the differed delay is called again. Args: - seconds (int/float): the delay in seconds from now. + seconds (int, float): the delay in seconds from now. obj (Object): the typecalssed object connected to the event. event_name (str): the event's name. - Note that 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. + Note: + 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() @@ -399,7 +428,7 @@ class TimeEventScript(DefaultScript): try: script = ScriptDB.objects.get(db_key="event_handler") except ScriptDB.DoesNotExist: - logger.log_err("Can't get the event handler.") + logger.log_trace("Can't get the event handler.") return if self.db.event_name and self.db.number is not None: @@ -434,13 +463,13 @@ def complete_task(task_id): This function should be called automatically for individual tasks. Args: - task_id (int): the task id. + task_id (int): the task ID. """ try: script = ScriptDB.objects.get(db_key="event_handler") except ScriptDB.DoesNotExist: - logger.log_err("Can't get the event handler.") + logger.log_trace("Can't get the event handler.") return if task_id not in script.db.tasks: diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index ebe55cb33..191d66f64 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -4,9 +4,9 @@ Patched typeclasses for Evennia. from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom from evennia import ScriptDB +from evennia.utils.utils import inherits_from from evennia.contrib.events.custom import create_event_type, patch_hook, \ create_time_event -from evennia.utils.utils import inherits_from class PatchedExit(object): @@ -52,10 +52,12 @@ create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], 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 - using this exit for the time being. The 'character' variable - contains the character who wants to traverse through this exit. - The 'exit' variable contains the exit, the 'room' variable - contains the room in which the character and exit are. + 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. """) create_event_type(DefaultExit, "traverse", ["character", "exit", "origin", "destination"], """ @@ -64,11 +66,12 @@ create_event_type(DefaultExit, "traverse", ["character", "exit", 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. - The 'character' variable contains the character who has traversed - through this exit. The 'exit' variable contains the exit, the - 'origin' variable contains the room in which the character was - before traversing, while 'destination' contains the room in which - the character now is. + + 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. """) # Room events @@ -82,7 +85,7 @@ create_event_type(DefaultRoom, "time", ["room"], """ 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 15th at 12 PM (still + 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). From 38563a6593ac1a76202c3ff7d77c6c8fe654664e Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 22 Mar 2017 16:57:36 -0700 Subject: [PATCH 070/133] Add an event handler on all objects --- evennia/contrib/events/custom.py | 2 +- evennia/contrib/events/handler.py | 182 ++++++++++++++++++++++++++ evennia/contrib/events/scripts.py | 40 +++++- evennia/contrib/events/typeclasses.py | 13 +- 4 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 evennia/contrib/events/handler.py diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index 7c35c5b2e..5a66c6369 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -67,7 +67,7 @@ def patch_hook(typeclass, method_name): inheritance tree for a couple of methods. """ - hook = getattr(typeclass, method_name) + hook = getattr(typeclass, method_name, None) def wrapper(method): """Wrapper around the hook.""" def overridden_hook(*args, **kwargs): diff --git a/evennia/contrib/events/handler.py b/evennia/contrib/events/handler.py new file mode 100644 index 000000000..8d7f06e33 --- /dev/null +++ b/evennia/contrib/events/handler.py @@ -0,0 +1,182 @@ +""" +Module containing the EventHandler for individual objects. +""" + +from collections import namedtuple + +class EventsHandler(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.events`) is a + shortcut to manipulating events within this object, getting, + adding, editing, deleting and calling them. + + """ + + script = None + + def __init__(self, obj): + self.obj = obj + + def all(self): + """ + Return all events linked to this object. + + Returns: + All events in a dictionary event_name: event}. The event + is returned as a namedtuple to simply manipulation. + + """ + events = {} + handler = type(self).script + if handler: + dicts = handler.get_events(self.obj) + for event_name, in_list in dicts.items(): + new_list = [] + for event in in_list: + event = self.format_event(event) + new_list.append(event) + + if new_list: + events[event_name] = new_list + + return events + + def get(self, event_name): + """ + Return the events associated with this name. + + Args: + event_name (str): the name of the event. + + This method returns a list of Event objects (namedtuple + representations). If the event name cannot be found in the + object's events, return an empty list. + + """ + return self.all().get(event_name, []) + + def add(self, event_name, code, author=None, valid=False, parameters=""): + """ + Add a new event for this object. + + Args: + event_name (str): the name of the event to add. + code (str): the Python code associated with this event. + author (Character or Player, optional): the author of the event. + valid (bool, optional): should the event be connected? + parameters (str, optional): optional parameters. + + Returns: + The event definition that was added or None. + + """ + handler = type(self).script + if handler: + return self.format_event(handler.add_event(self.obj, event_name, code, + author=author, valid=valid, parameters=parameters)) + + def edit(self, event_name, number, code, author=None, valid=False): + """ + Edit an existing event bound to this object. + + Args: + event_name (str): the name of the event to edit. + number (int): the event number to be changed. + code (str): the Python code associated with this event. + author (Character or Player, optional): the author of the event. + valid (bool, optional): should the event be connected? + + Returns: + The event definition that was edited or None. + + Raises: + RuntimeError if the event is locked. + + """ + handler = type(self).script + if handler: + return self.format_event(handler.edit_event(self.obj, event_name, + number, code, author=author, valid=valid)) + + def remove(self, event_name, number): + """ + Delete the specified event bound to this object. + + Args: + event_name (str): the name of the event to delete. + number (int): the number of the event to delete. + + Raises: + RuntimeError if the event is locked. + + """ + handler = type(self).script + if handler: + handler.del_event(self.obj, event_name, number) + + def call(self, event_name, *args, **kwargs): + """ + Call the specified event(s) bound to this object. + + Args: + event_name (str): the event name to call. + *args: additional variables for this event. + + Kwargs: + number (int, optional): call just a specific event. + parameters (str, optional): call an event with parameters. + locals (dict, optional): a locals replacement. + + Returns: + True to report the event was called without interruption, + False otherwise. + + """ + handler = type(self).script + if handler: + return handler.call_event(self.obj, event_name, *args, **kwargs) + + return False + + @staticmethod + def format_event(event): + """ + Return the Event namedtuple to represent the specified event. + + Args: + event (dict): the event definition. + + The event given in argument should be a dictionary containing + the expected fields for an event (code, author, valid...). + + """ + if "obj" not in event: + event["obj"] = None + if "name" not in event: + event["name"] = "unknown" + if "number" not in event: + event["number"] = -1 + if "code" not in event: + event["code"] = "" + if "author" not in event: + event["author"] = None + if "valid" not in event: + event["valid"] = False + if "parameters" not in event: + event["parameters"] = "" + if "created_on" not in event: + event["created_on"] = None + if "updated_by" not in event: + event["updated_by"] = None + if "updated_on" not in event: + event["updated_on"] = None + + return Event(**event) + +Event = namedtuple("Event", ("obj", "name", "number", "code", "author", + "valid", "parameters", "created_on", "updated_by", "updated_on")) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index d0abdc1dc..c3f34e462 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -6,13 +6,14 @@ from datetime import datetime, timedelta from Queue import Queue from django.conf import settings -from evennia import DefaultScript, ScriptDB +from evennia import DefaultObject, DefaultScript, ScriptDB from evennia import logger from evennia.utils.dbserialize import dbserialize from evennia.utils.utils import all_from_module, delay -from evennia.contrib.events.custom import connect_event_types, \ - get_next_wait, patch_hooks +from evennia.contrib.events.custom import ( + connect_event_types, get_next_wait, patch_hooks) from evennia.contrib.events.exceptions import InterruptEvent +from evennia.contrib.events.handler import EventsHandler as Handler from evennia.contrib.events import typeclasses class EventHandler(DefaultScript): @@ -65,6 +66,10 @@ class EventHandler(DefaultScript): delay(seconds, complete_task, task_id) + # Place the script in the EventsHandler + Handler.script = self + DefaultObject.events = typeclasses.PatchedObject.events + def get_events(self, obj): """ Return a dictionary of the object's events. @@ -80,7 +85,21 @@ class EventHandler(DefaultScript): when several objects would share events. """ - return self.db.events.get(obj, {}) + obj_events = self.db.events.get(obj, {}) + events = {} + for event_name, event_list in obj_events.items(): + new_list = [] + for i, event in enumerate(event_list): + event = dict(event) + event["obj"] = obj + event["name"] = event_name + event["number"] = i + new_list.append(event) + + if new_list: + events[event_name] = new_list + + return events def get_event_types(self, obj): """ @@ -212,6 +231,13 @@ class EventHandler(DefaultScript): elif valid and (obj, event_name, number) in self.db.to_valid: self.db.to_valid.remove((obj, event_name, number)) + # Build the definition to return (a dictionary) + definition = dict(events[number]) + definition["obj"] = obj + definition["name"] = event_name + definition["number"] = number + return definition + def del_event(self, obj, event_name, number): """ Delete the specified event. @@ -259,11 +285,11 @@ class EventHandler(DefaultScript): i += 1 # Update locked event - for line in self.db.locked: + for i, line in enumerate(self.db.locked): t_obj, t_event_name, t_number = line if obj is t_obj and event_name == t_event_name: - if number > t_number: - line[2] -= 1 + if number < t_number: + self.db.locked[i] = (t_obj, t_event_name, t_number - 1) # Delete time-related events associated with this object for script in list(obj.scripts.all()): diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 191d66f64..016c87992 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -4,9 +4,16 @@ Patched typeclasses for Evennia. from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom from evennia import ScriptDB -from evennia.utils.utils import inherits_from -from evennia.contrib.events.custom import create_event_type, patch_hook, \ - create_time_event +from evennia.utils.utils import inherits_from, lazy_property +from evennia.contrib.events.custom import ( + create_event_type, patch_hook, create_time_event) +from evennia.contrib.events.handler import EventsHandler + +class PatchedObject(object): + @lazy_property + def events(self): + """Return the EventsHandler.""" + return EventsHandler(self) class PatchedExit(object): From 9c091b29e94a7ac373b2124f981d9cf8977511b8 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sat, 25 Mar 2017 13:05:14 -0700 Subject: [PATCH 071/133] Fix some minor bugs in event connection/locking --- evennia/contrib/events/commands.py | 13 +++++++++---- evennia/contrib/events/custom.py | 7 ++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 9335af5b1..b0fbb4d6b 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -254,7 +254,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): row.append("Yes" if event.get("valid") else "No") table.add_row(*row) - self.msg(table) + self.msg(unicode(table)) else: names = list(set(list(types.keys()) + list(events.keys()))) table = EvTable("Event name", "Number", "Description", @@ -271,7 +271,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): description = description.splitlines()[0] table.add_row(name, no, description) - self.msg(table) + self.msg(unicode(table)) def add_event(self): """Add an event.""" @@ -292,6 +292,11 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # Open the editor event = self.handler.add_event(obj, event_name, "", self.caller, False, parameters=self.parameters) + + # Lock this event right away + self.handler.db.locked.append((obj, event_name, event["number"])) + + # Open the editor for this event self.caller.db._event = event EvEditor(self.caller, loadfunc=_ev_load, savefunc=_ev_save, quitfunc=_ev_quit, key="Event {} of {}".format( @@ -456,7 +461,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): updated_on = "|gUnknown|n" table.add_row(obj.id, type_name, obj, name, by, updated_on) - self.msg(table) + self.msg(unicode(table)) return # An object was specified @@ -517,7 +522,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): delta = time_format((future - now).total_seconds(), 1) table.add_row(task_id, key, event_name, delta) - self.msg(table) + self.msg(unicode(table)) # Private functions to handle editing def _ev_load(caller): diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index 5a66c6369..e82441aea 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -114,9 +114,10 @@ def connect_event_types(): if script.ndb.event_types is None: return - while event_types: + t_event_types = list(event_types) + while t_event_types: typeclass_name, event_name, variables, help_text, \ - custom_add, custom_call = event_types[0] + custom_add, custom_call = t_event_types[0] # Get the event types for this typeclass if typeclass_name not in script.ndb.event_types: @@ -126,7 +127,7 @@ def connect_event_types(): # Add or replace the event help_text = dedent(help_text.strip("\n")) types[event_name] = (variables, help_text, custom_add, custom_call) - del event_types[0] + del t_event_types[0] # Custom callbacks for specific event types def get_next_wait(format): From c996e8c4b5345b921c0ed14dde56ff7fb2f41c9f Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 26 Mar 2017 13:00:42 -0700 Subject: [PATCH 072/133] Add events on exits --- evennia/contrib/events/commands.py | 4 +- evennia/contrib/events/handler.py | 17 +++ evennia/contrib/events/scripts.py | 25 +++++ evennia/contrib/events/typeclasses.py | 151 +++++++++++++++++++++++++- 4 files changed, 191 insertions(+), 6 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index b0fbb4d6b..7583f657a 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -287,7 +287,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): definition = types.get(event_name, (None, "Chain event")) description = definition[1] - self.msg(description) + self.msg(raw(description)) # Open the editor event = self.handler.add_event(obj, event_name, "", @@ -356,7 +356,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # Check the definition of the event definition = types.get(event_name, (None, "Chained event")) description = definition[1] - self.msg(description) + self.msg(raw(description)) # Open the editor event = dict(event) diff --git a/evennia/contrib/events/handler.py b/evennia/contrib/events/handler.py index 8d7f06e33..5a85623af 100644 --- a/evennia/contrib/events/handler.py +++ b/evennia/contrib/events/handler.py @@ -60,6 +60,23 @@ class EventsHandler(object): """ return self.all().get(event_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, event_name, code, author=None, valid=False, parameters=""): """ Add a new event for this object. diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index c3f34e462..2709fb6c6 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -132,6 +132,31 @@ class EventHandler(DefaultScript): return types + 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 add_event(self, obj, event_name, code, author=None, valid=False, parameters=""): """ diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 016c87992..f7ab05d8c 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -9,6 +9,105 @@ from evennia.contrib.events.custom import ( create_event_type, patch_hook, create_time_event) from evennia.contrib.events.handler import EventsHandler +class PatchedCharacter: + + """Patched typeclass for DefaultCharcter.""" + + @staticmethod + @patch_hook(DefaultCharacter, "announce_move_from") + def announce_move_from(character, destination, msg=None, hook=None): + """ + Called if the move is to be announced. This is + called while we are still standing in the old + location. Customizing the message through events is possible. + + Args: + destination (Object): The place we are going to. + msg (optional): a custom message to replace the default one. + + """ + if not character.location: + return + + if msg: + string = msg + else: + string = "{character} is leaving {origin}, heading for {destination}." + + # Get the exit from location to destination + location = character.location + exits = [o for o in location.contents if o.location is location and o.destination is destination] + if exits: + exits[0].events.call("msg_leave", character, exits[0], + location, destination, string) + string = exits[0].events.get_variable("message") + + mapping = { + "character": character, + "exit": exits[0] if exits else "somewhere", + "origin": location or "nowhere", + "destination": destination or "nowhere", + } + + # 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 + + location.msg_contents(string, exclude=(character, ), mapping=mapping) + + @staticmethod + @patch_hook(DefaultCharacter, "announce_move_to") + def announce_move_to(character, source_location, msg=None, hook=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 default message to be displayed. + + """ + + if not source_location and character.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) + character.location.msg(string) + return + + if source_location: + if msg: + string = msg + else: + string = "{character} arrives to {destination} from {origin}." + else: + string = "{character} arrives to {destination}." + + origin = source_location + destination = character.location + if origin: + exits = [o for o in destination.contents if o.location is destination and o.destination is origin] + if exits: + exits[0].events.call("msg_arrive", character, exits[0], + origin, destination, string) + string = exits[0].events.get_variable("message") + + mapping = { + "character": character, + "exit": exits[0] if exits else "somewhere", + "origin": origin or "nowhere", + "destination": destination or "nowhere", + } + + # 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 + + destination.msg_contents(string, exclude=(character, ), mapping=mapping) + + class PatchedObject(object): @lazy_property def events(self): @@ -37,9 +136,8 @@ class PatchedExit(object): """ is_character = inherits_from(traversing_object, DefaultCharacter) - script = ScriptDB.objects.get(db_key="event_handler") if is_character: - allow = script.call_event(exit, "can_traverse", traversing_object, + allow = exit.events.call("can_traverse", traversing_object, exit, exit.location) if not allow: return @@ -48,7 +146,7 @@ class PatchedExit(object): # After traversing if is_character: - script.call_event(exit, "traverse", traversing_object, + exit.events.call("traverse", traversing_object, exit, exit.location, exit.destination) @@ -66,6 +164,51 @@ create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], exit: the exit to be traversed. room: the room in which stands the character before moving. """) +create_event_type(DefaultExit, "msg_arrive", ["character", "exit", + "origin", "destination", "message"], """ + 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. +""") +create_event_type(DefaultExit, "msg_leave", ["character", "exit", + "origin", "destination", "message"], """ + 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. +""") create_event_type(DefaultExit, "traverse", ["character", "exit", "origin", "destination"], """ After the characer has traversed through this exit. @@ -98,4 +241,4 @@ create_event_type(DefaultRoom, "time", ["room"], """ Variables you can use in this event: room: the room connected to this event. -""", create_time_event) + """, create_time_event) From fae58778436a894f8770da0f876d20bfe88a8be9 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 26 Mar 2017 13:34:47 -0700 Subject: [PATCH 073/133] Fix a bug in Character.announce_move_to --- evennia/contrib/events/typeclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index f7ab05d8c..b7cf20e24 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -91,7 +91,7 @@ class PatchedCharacter: if exits: exits[0].events.call("msg_arrive", character, exits[0], origin, destination, string) - string = exits[0].events.get_variable("message") + string = exits[0].events.get_variable("message") mapping = { "character": character, From d483726d54f99bd3643960d47d34abfd5a824bd5 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 26 Mar 2017 15:55:16 -0700 Subject: [PATCH 074/133] Add unittests for the event system --- evennia/contrib/events/tests.py | 492 ++++++++++++++++++++++++++ evennia/contrib/events/typeclasses.py | 17 + 2 files changed, 509 insertions(+) create mode 100644 evennia/contrib/events/tests.py diff --git a/evennia/contrib/events/tests.py b/evennia/contrib/events/tests.py new file mode 100644 index 000000000..0dd2bbf8f --- /dev/null +++ b/evennia/contrib/events/tests.py @@ -0,0 +1,492 @@ +""" +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 CmdEvent + +# Force settings +settings.EVENTS_CALENDAR = "standard" + +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") + + def tearDown(self): + """Stop the event handler.""" + self.handler.stop() + super(TestEventHandler, self).tearDown() + + def test_start(self): + """Simply make sure the handler runs with proper initial values.""" + self.assertEqual(self.handler.db.events, {}) + self.assertEqual(self.handler.db.to_valid, []) + self.assertEqual(self.handler.db.locked, []) + self.assertEqual(self.handler.db.tasks, {}) + self.assertEqual(self.handler.db.task_id, 0) + self.assertIsNotNone(self.handler.ndb.event_types) + + def test_add(self): + """Add a single event on room1.""" + author = self.char1 + self.handler.add_event(self.room1, "dummy", + "character.db.strength = 50", author=author, valid=True) + event = self.handler.get_events(self.room1).get("dummy") + event = event[0] + self.assertIsNotNone(event) + self.assertEqual(event["obj"], self.room1) + self.assertEqual(event["name"], "dummy") + self.assertEqual(event["number"], 0) + self.assertEqual(event["author"], author) + self.assertEqual(event["valid"], True) + + # Since this event is valid, it shouldn't appear in 'to_valid' + self.assertNotIn((self.room1, "dummy", 0), self.handler.db.to_valid) + + # Run this dummy event + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call_event( + self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 50) + + def test_add_validation(self): + """Add an event while needing validation.""" + author = self.char1 + self.handler.add_event(self.room1, "dummy", + "character.db.strength = 40", author=author, valid=False) + event = self.handler.get_events(self.room1).get("dummy") + event = event[0] + self.assertIsNotNone(event) + self.assertEqual(event["author"], author) + self.assertEqual(event["valid"], False) + + # Since this event is not valid, it should appear in 'to_valid' + self.assertIn((self.room1, "dummy", 0), self.handler.db.to_valid) + + # Run this dummy event (shouldn't do anything) + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call_event( + self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 10) + + def test_edit(self): + """Test editing an event.""" + author = self.char1 + self.handler.add_event(self.room1, "dummy", + "character.db.strength = 60", author=author, valid=True) + + # Edit it right away + self.handler.edit_event(self.room1, "dummy", 0, + "character.db.strength = 65", author=self.char2, valid=True) + + # Check that the event was written + event = self.handler.get_events(self.room1).get("dummy") + event = event[0] + self.assertIsNotNone(event) + self.assertEqual(event["author"], author) + self.assertEqual(event["valid"], True) + self.assertEqual(event["updated_by"], self.char2) + + # Run this dummy event + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call_event( + self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 65) + + def test_edit_validation(self): + """Edit an event when validation isn't automatic.""" + author = self.char1 + self.handler.add_event(self.room1, "dummy", + "character.db.strength = 70", author=author, valid=True) + + # Edit it right away + self.handler.edit_event(self.room1, "dummy", 0, + "character.db.strength = 80", author=self.char2, valid=False) + + # Run this dummy event (shouldn't do anything) + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call_event( + self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 10) + + def test_del(self): + """Try to delete an event.""" + # Add 3 events + self.handler.add_event(self.room1, "dummy", + "character.db.strength = 5", author=self.char1, valid=True) + self.handler.add_event(self.room1, "dummy", + "character.db.strength = 8", author=self.char2, valid=False) + self.handler.add_event(self.room1, "dummy", + "character.db.strength = 9", author=self.char1, valid=True) + + # Note that the second event isn't valid + self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid) + + # Lock the third event + self.handler.db.locked.append((self.room1, "dummy", 2)) + + # Delete the first event + self.handler.del_event(self.room1, "dummy", 0) + + # The event #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) event + self.handler.del_event(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 event + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call_event( + self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 9) + + def test_accept(self): + """Accept an event.""" + # Add 2 events + self.handler.add_event(self.room1, "dummy", + "character.db.strength = 5", author=self.char1, valid=True) + self.handler.add_event(self.room1, "dummy", + "character.db.strength = 8", author=self.char2, valid=False) + + # Note that the second event isn't valid + self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid) + + # Accept the second event + self.handler.accept_event(self.room1, "dummy", 1) + event = self.handler.get_events(self.room1).get("dummy") + event = event[1] + self.assertIsNotNone(event) + self.assertEqual(event["valid"], True) + + # Call the dummy event + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call_event( + self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 8) + + def test_call(self): + """Test to call amore complex event.""" + self.char1.key = "one" + self.char2.key = "two" + + # Add an event + code = dedent(""" + if character.key == "one": + character.db.health = 50 + else: + character.db.health = 0 + """.strip("\n")) + self.handler.add_event(self.room1, "dummy", code, + author=self.char1, valid=True) + + # Call the dummy event + self.assertTrue(self.handler.call_event( + self.room1, "dummy", locals={"character": self.char1})) + self.assertEqual(self.char1.db.health, 50) + self.assertTrue(self.handler.call_event( + 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.events) + + # Add an event + event = self.room1.events.add("say", "pass", author=self.char1, + valid=True) + self.assertEqual(event.obj, self.room1) + self.assertEqual(event.name, "say") + self.assertEqual(event.code, "pass") + self.assertEqual(event.author, self.char1) + self.assertEqual(event.valid, True) + self.assertIn([event], self.room1.events.all().values()) + + # Edit this very event + new = self.room1.events.edit("say", 0, "character.db.say = True", + author=self.char1, valid=True) + self.assertIn([new], self.room1.events.all().values()) + self.assertNotIn([event], self.room1.events.all().values()) + + # Try to call this event + self.assertTrue(self.room1.events.call("say", + locals={"character": self.char2})) + self.assertTrue(self.char2.db.say) + + # Delete the event + self.room1.events.remove("say", 0) + self.assertEqual(self.room1.events.all(), {}) + + +class TestCmdEvent(CommandTest): + + """Test the @event command.""" + + def setUp(self): + """Create the event handler.""" + super(TestCmdEvent, self).setUp() + self.handler = create_script( + "evennia.contrib.events.scripts.EventHandler") + + def tearDown(self): + """Stop the event handler.""" + self.handler.stop() + for script in ScriptDB.objects.filter( + db_typeclass_path="evennia.contrib.events.scripts.TimeEventScript"): + script.stop() + + super(TestCmdEvent, self).tearDown() + + def test_list(self): + """Test listing events with different rights.""" + table = self.call(CmdEvent(), "out") + lines = table.splitlines()[3:-1] + self.assertNotEqual(lines, []) + + # Check that the second column only contains 0 (0) (no event yet) + for line in lines: + cols = line.split("|") + self.assertIn(cols[2].strip(), ("0 (0)", "")) + + # Add some event + self.handler.add_event(self.exit, "traverse", "pass", + author=self.char1, valid=True) + + # Try to obtain more details on a specific event on exit + table = self.call(CmdEvent(), "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(CmdEvent(), "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 event + # The last line should be "pass" (the event code) + details = self.call(CmdEvent(), "out = traverse 1") + self.assertEqual(details.splitlines()[-1], "pass") + + def test_add(self): + """Test to add an event.""" + self.call(CmdEvent(), "/add out = traverse") + editor = self.char1.ndb._eveditor + self.assertIsNotNone(editor) + + # Edit the event + 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() + event = self.exit.events.get("traverse")[0] + self.assertEqual(event.author, self.char1) + self.assertEqual(event.valid, True) + self.assertTrue(len(event.code) > 0) + + # We're going to try the same thing but with char2 + # char2 being a player for our test, the event won't be validated. + er = self.call(CmdEvent(), "/add out = traverse", caller=self.char2) + editor = self.char2.ndb._eveditor + self.assertIsNotNone(editor) + + # Edit the event + editor.update_buffer(dedent(""" + character.msg("No way.") + """.strip("\n"))) + editor.save_buffer() + editor.quit() + event = self.exit.events.get("traverse")[1] + self.assertEqual(event.author, self.char2) + self.assertEqual(event.valid, False) + self.assertTrue(len(event.code) > 0) + + def test_del(self): + """Add and remove an event.""" + self.handler.add_event(self.exit, "traverse", "pass", + author=self.char1, valid=True) + + # Try to delete the event + # char2 shouldn't be allowed to do so (that's not HIS event) + self.call(CmdEvent(), "/del out = traverse 1", caller=self.char2) + self.assertTrue(len(self.handler.get_events(self.exit).get( + "traverse", [])) == 1) + + # Now, char1 should be allowed to delete it + self.call(CmdEvent(), "/del out = traverse 1") + self.assertTrue(len(self.handler.get_events(self.exit).get( + "traverse", [])) == 0) + + def test_lock(self): + """Test the lock of multiple editing.""" + self.call(CmdEvent(), "/add here = time 8:00", caller=self.char2) + self.assertIsNotNone(self.char2.ndb._eveditor) + + # Now ask char1 to edit + line = self.call(CmdEvent(), "/edit here = time 1") + self.assertIsNone(self.char1.ndb._eveditor) + + # Try to delete this event while char2 is editing it + line = self.call(CmdEvent(), "/del here = time 1") + + def test_accept(self): + """Accept an event.""" + self.call(CmdEvent(), "/add here = time 8:00", caller=self.char2) + editor = self.char2.ndb._eveditor + self.assertIsNotNone(editor) + + # Edit the event + editor.update_buffer(dedent(""" + room.msg_contents("It's 8 PM, everybody up!") + """.strip("\n"))) + editor.save_buffer() + editor.quit() + event = self.room1.events.get("time")[0] + self.assertEqual(event.valid, False) + + # chars shouldn't be allowed to the event + self.call(CmdEvent(), "/accept here = time 1", caller=self.char2) + event = self.room1.events.get("time")[0] + self.assertEqual(event.valid, False) + + # char1 will accept the event + self.call(CmdEvent(), "/accept here = time 1") + event = self.room1.events.get("time")[0] + self.assertEqual(event.valid, True) + + +class TestDefaultEvents(CommandTest): + + """Test the default events.""" + + def setUp(self): + """Create the event handler.""" + super(TestDefaultEvents, self).setUp() + self.handler = create_script( + "evennia.contrib.events.scripts.EventHandler") + + def tearDown(self): + """Stop the event handler.""" + self.handler.stop() + super(TestDefaultEvents, self).tearDown() + + def test_exit(self): + """Test the events 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")) + # Try the can_traverse event + self.handler.add_event(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 event + self.handler.del_event(self.exit, "can_traverse", 0) + self.handler.add_event(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_event(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_event(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_event(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 index b7cf20e24..86d8d1c15 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -209,6 +209,23 @@ create_event_type(DefaultExit, "msg_leave", ["character", "exit", destination: the destination of the character. message: the message to be displayed in the location. """) +create_event_type(DefaultExit, "time", ["exit"], """ + 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. + """, create_time_event) create_event_type(DefaultExit, "traverse", ["character", "exit", "origin", "destination"], """ After the characer has traversed through this exit. From 44a73acd94fbc4fe610fc59541dd7b0bbd7b2a00 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 26 Mar 2017 16:06:26 -0700 Subject: [PATCH 075/133] Chained events can now be called without delay --- evennia/contrib/events/helpers.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/evennia/contrib/events/helpers.py b/evennia/contrib/events/helpers.py index 6675e3ded..4f6ad17cc 100644 --- a/evennia/contrib/events/helpers.py +++ b/evennia/contrib/events/helpers.py @@ -75,10 +75,12 @@ def call(obj, event_name, seconds=0): to be called from inside another event. """ - try: - script = ScriptDB.objects.get(db_key="event_handler") - except ScriptDB.DoesNotExist: - return - - # Schedule the task - script.set_task(seconds, obj, event_name) + script = type(obj.events).script + if script: + # If seconds is 0, call the event immediately + if seconds == 0: + locals = dict(script.ndb.current_locals) + obj.events.call(event_name, locals=locals) + else: + # Schedule the task + script.set_task(seconds, obj, event_name) From 1cfaf77df7a38c2fb9bd22874281397b805fbbdb Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 27 Mar 2017 13:41:42 -0700 Subject: [PATCH 076/133] Add an error handler if errors occurr during event execution Optimize time events with fewer restarts --- evennia/contrib/events/custom.py | 21 ++++++- evennia/contrib/events/scripts.py | 101 +++++++++++++++++++++--------- 2 files changed, 89 insertions(+), 33 deletions(-) diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index e82441aea..f39e1a996 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -14,6 +14,7 @@ from evennia import ScriptDB from evennia.utils.create import create_script from evennia.utils.gametime import real_seconds_until as standard_rsu 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 hooks = [] @@ -141,6 +142,11 @@ def get_next_wait(format): number of units set in the calendar affects the way seconds are calculated. + 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. + """ calendar = getattr(settings, "EVENTS_CALENDAR", None) if calendar is None: @@ -179,12 +185,20 @@ def get_next_wait(format): 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) - seconds = rsu(**params) - return seconds, details + until = rsu(**params) + usual = -1 + if next_unit: + kwargs = {next_unit: 1} + usual = gametime_to_realtime(**kwargs) + return until, usual, details def create_time_event(obj, event_name, number, parameters): """ @@ -197,12 +211,13 @@ def create_time_event(obj, event_name, number, parameters): parameters (str): the parameter of the event. """ - seconds, key = get_next_wait(parameters) + 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(events, parameters): """ diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 2709fb6c6..38625de4d 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -4,10 +4,14 @@ 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, ScriptDB +from evennia import DefaultObject, DefaultScript, ChannelDB, ScriptDB from evennia import logger +from evennia.utils.create import create_channel from evennia.utils.dbserialize import dbserialize from evennia.utils.utils import all_from_module, delay from evennia.contrib.events.custom import ( @@ -16,6 +20,9 @@ from evennia.contrib.events.exceptions import InterruptEvent from evennia.contrib.events.handler import EventsHandler as Handler from evennia.contrib.events import typeclasses +# Constants +RE_LINE_ERROR = re.compile(r'^ File "\", line (\d+)') + class EventHandler(DefaultScript): """ @@ -70,6 +77,13 @@ class EventHandler(DefaultScript): Handler.script = self DefaultObject.events = typeclasses.PatchedObject.events + # 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 the object's events. @@ -393,9 +407,7 @@ class EventHandler(DefaultScript): else: locals = {key: value for key, value in locals.items()} - events = self.db.events.get(obj, {}).get(event_name, []) - - # Filter down of events if there is a custom call + events = self.get_events(obj).get(event_name, []) if event_type: custom_call = event_type[3] if custom_call: @@ -407,13 +419,41 @@ class EventHandler(DefaultScript): if not event["valid"]: continue - if number is not None and i != number: + if number is not None and event["number"] != number: continue try: exec(event["code"], locals, locals) except InterruptEvent: return False + except Exception: + etype, evalue, tb = sys.exc_info() + trace = traceback.format_exception(etype, evalue, tb) + number = event["number"] + logger.log_err("An error occurred during the event {} of " \ + "{}, number {}\n{}".format(event_name, obj, + number + 1, "\n".join(trace))) + + # Inform the 'everror' channel + 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 = event["code"].splitlines()[lineno - 1] + except IndexError: + continue + else: + break + + self.ndb.channel.msg("Error in {} of {}[{}], line {}:" \ + " {}\n {}".format(event_name, obj, + number + 1, lineno, line, repr(evalue))) return True @@ -474,36 +514,37 @@ class TimeEventScript(DefaultScript): self.db.number = None def at_repeat(self): - """Call the event and reset interval.""" - # Get the event handler and call the script - try: - script = ScriptDB.objects.get(db_key="event_handler") - except ScriptDB.DoesNotExist: - logger.log_trace("Can't get the event handler.") - return + """ + 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.events: + return + event_name = self.db.event_name number = self.db.number - events = script.db.events.get(obj, {}).get(event_name) - if events is None: - logger.log_err("Cannot find the event {} on {}".format( - event_name, obj)) - return - - try: - event = events[number] - except IndexError: - logger.log_err("Cannot find the event {} {} on {}".format( - event_name, number, obj)) - return - - script.call_event(obj, event_name, obj, number=number) - - if self.db.time_format: - seconds, details = get_next_wait(self.db.time_format) - self.restart(interval=seconds) + obj.events.call(event_name, obj, number=number) # Functions to manipulate tasks From 52505a705a7eeec94339b703d2239113f1b2aa82 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 27 Mar 2017 16:37:33 -0700 Subject: [PATCH 077/133] Update the user documentation of the event system --- evennia/contrib/events/USERDOC.md | 39 ++++++++++++++++++++++--------- evennia/contrib/events/scripts.py | 9 +++---- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/evennia/contrib/events/USERDOC.md b/evennia/contrib/events/USERDOC.md index a89e30097..c333cf5d9 100644 --- a/evennia/contrib/events/USERDOC.md +++ b/evennia/contrib/events/USERDOC.md @@ -33,7 +33,7 @@ The `deny()` function denies characters from moving and so, after the message ha This time, we have an event that behaves differently when a character eats an apple... and is a goblin, or something else. Notice that the race system will need to be in your game, the event system just provides ways to access your regular Evennia objects and attributes. Edit the event 'time' of a specific NPC with the parameter '19:45': - cmd(character, "say Well, it's time to go home, folks!") + character.execute_cmd("say Well, it's time to go home, folks!") exit = character.location.search("up") exit.db.lock = False @@ -66,11 +66,23 @@ The object to display or edit is searched in the room, by default, which makes e (In most settings, this will show the events linked with the character 1, the superuser.) -By default, if you try this command on an object that doesn't have any event, it should display something like: +This command will display a table, containing: - No event has been defined in TYPE DISPLAY_NAME. +- The name of each event in the first column. +- The number of events of this name, and the number of total lines of these events in the second column. +- A short help to tell you when the event is triggered in the third column. -If there are events linked to this object, you will see them in a table (with the event and the number of line). +Notice that several events can be linked at the same location. For instance, you can have several events in an exit's "can_traverse" event: each event will be called in the order and each can prevent the character from going elsewhere. + +You can see the list of events of each name by using the same command, specifying the name of the event after an equal sign: + + @event south = can_traverse + +If you have more than one event of this name, they will be shown in a table with numbers starting from 1. You can examine a specific event by providing the number after the event's name: + + @event south = can_traverse 1 + +This command will allow you to examine the event more closely, including seeing its associated code. #### Creating a new event @@ -173,12 +185,21 @@ The `deny()` function is such a helper. It allows to interrupt the event and th Behind the scene, the `deny()` function raises an exception that is being intercepted by the handler of events. Calling this function in events that cannot be stopped may result in errors. -You could easily add other helper functions. This will greatly depend on the objects you have defined in your game, and how often specific features have to be used by event users. You will find a list of helper functions, their syntax and examples, in the documentation on events specific to you game (see below). +You could easily add other helper functions. This will greatly depend on the objects you have defined in your game, and how often specific features have to be used by event users. ### Variables in events Most events have variables. Variables are just Python variables. 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. The list of variables can change between events, and is always available in the help of the event. When you edit or add a new event, you'll see the help: read it carefully until you're familiar with this event, since it will give you useful information beyond the list of variables. +Sometimes, variables in events can also be set to contain new directions. One simple example is the exits' "msg_leave" event, that is called when a character leaves a room through this exit. This event is executed and you can set a custom message when a character walks through this exit, which can sometimes be useful: + + @event/add down = msg_leave + message = "{character} falls into a hole in the ground!" + +Then, if the character Wilfred takes this story, others in the room will see: + + Wildred falls into a hole in the ground! + ### Events with parameters Some events are called without parameter. For instance, when a character traverses through an exit, the exit's "traverse" event is called with no argument. In some cases, you can create events 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. The event can be configured to fire only when some words are used in the sentence. @@ -296,10 +317,6 @@ A word of caution on events that call chained events: it isn't impossible for an 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()`, 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. -## Getting help on events - -It is not always the case, but game developers who are using the event system are encouraged to allow the system to create and maintain an automatic help file that contains the basic explanation of events, how to use them and, more importantly, the list of available helpers. This is important, because each game can append new helpers, and this documentation only shows a few, common one: the automatically-generated help file allows you to check what helpers exist and how to use them. If set, you can enter `help events` to see it. If not, you might want to ask a game developer to have access to this information. - ## Errors in events There are a lot of ways to make mistakes while writing events. Once you begin, you might encounter syntax errors very often, but leave them behind as you gain in confidence. However, there are still so many ways to trigger errors: passing the wrong arguments to a helper function is only one of many possible examples. @@ -307,9 +324,9 @@ There are a lot of ways to make mistakes while writing events. Once you begin, When an event encounters an error, it stops abruptly and sends the error on a special channel, named "everror", on which you can connect or disconnect should the amount of information be overwhelming. These error messages will contain: - The name and ID of the object that encountered the error. -- The name of the event, with possible parameters, that crashed. +- The name and number of the event that crashed. +- The line number (and code) that caused the error. - The short error messages (it might not be that short at times). The error will also be logged, so an administrator can still access it more completely, seeing the full traceback, which can help to understand the error sometimes. - diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 38625de4d..a3b7d2e3b 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -430,9 +430,10 @@ class EventHandler(DefaultScript): etype, evalue, tb = sys.exc_info() trace = traceback.format_exception(etype, evalue, tb) number = event["number"] + oid = obj.id logger.log_err("An error occurred during the event {} of " \ - "{}, number {}\n{}".format(event_name, obj, - number + 1, "\n".join(trace))) + "{} (#{}), number {}\n{}".format(event_name, obj, + oid, number + 1, "\n".join(trace))) # Inform the 'everror' channel line = "|runknown|n" @@ -451,9 +452,9 @@ class EventHandler(DefaultScript): else: break - self.ndb.channel.msg("Error in {} of {}[{}], line {}:" \ + self.ndb.channel.msg("Error in {} of {} (#{})[{}], line {}:" \ " {}\n {}".format(event_name, obj, - number + 1, lineno, line, repr(evalue))) + oid, number + 1, lineno, line, repr(evalue))) return True From fb299adaaa9eb7ae05da70097a45c6dad236f78b Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 28 Mar 2017 17:30:48 -0700 Subject: [PATCH 078/133] Fix a locking error when quitting the EvEditor without saving. Add events on characters and rooms. --- evennia/contrib/events/commands.py | 15 +- evennia/contrib/events/typeclasses.py | 239 ++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 7583f657a..f171e1799 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -421,8 +421,8 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): # Delete the event self.handler.del_event(obj, event_name, number) - self.msg("The event {} {} of {} was deleted.".format( - obj, event_name, parameters)) + self.msg("The event {}[{}] of {} was deleted.".format( + event_name, number + 1, obj)) def accept_event(self): """Accept an event.""" @@ -549,5 +549,16 @@ def _ev_save(caller, buf): return True def _ev_quit(caller): + event = caller.db._event + handler = get_event_handler() + if not handler or not event or not all(key in event for key in \ + ("obj", "name", "number", "valid")): + caller.msg("Couldn't save this event.") + return False + + if (event["obj"], event["name"], event["number"]) in handler.db.locked: + handler.db.locked.remove((event["obj"], event["name"], + event["number"])) + del caller.db._event caller.msg("Exited the code editor.") diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 86d8d1c15..4e1172513 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -107,6 +107,110 @@ class PatchedCharacter: destination.msg_contents(string, exclude=(character, ), mapping=mapping) + @staticmethod + @patch_hook(DefaultCharacter, "at_before_move") + def at_before_move(character, destination, hook=None): + """ + 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 = character.location + Room = DefaultRoom + if isinstance(origin, Room) and isinstance(destination, Room): + can = character.events.call("can_move", character, + origin, destination) + if can: + can = origin.events.call("can_move", character, origin) + + return can + + return True + + @staticmethod + @patch_hook(DefaultCharacter, "at_after_move") + def at_after_move(character, source_location, hook=None): + """ + 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`. + + """ + hook(character, source_location) + origin = source_location + destination = character.location + Room = DefaultRoom + if isinstance(origin, Room) and isinstance(destination, Room): + character.events.call("move", character, origin, destination) + destination.events.call("move", character, origin, destination) + + # Call the 'greet' event of characters in the location + for present in [o for o in destination.contents if isinstance( + o, DefaultCharacter)]: + present.events.call("greet", present, character) + + @staticmethod + @patch_hook(DefaultCharacter, "at_object_delete") + def at_object_delete(character, hook=None): + """ + Called just before the database object is permanently + delete()d from the database. If this method returns False, + deletion is aborted. + + """ + if not character.events.call("can_delete", character): + return False + + character.events.call("delete", character) + return True + + @staticmethod + @patch_hook(DefaultCharacter, "at_post_puppet") + def at_post_puppet(character, hook=None): + """ + 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. + + """ + hook(character) + character.events.call("puppeted", character) + + @staticmethod + @patch_hook(DefaultCharacter, "at_pre_unpuppet") + def at_pre_unpuppet(character, hook=None): + """ + 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. + + """ + character.events.call("unpuppeted", character) + hook(character) + class PatchedObject(object): @lazy_property @@ -150,7 +254,101 @@ class PatchedExit(object): exit, exit.location, exit.destination) +class PatchedRoom: + + """Soft-patching of room's default hooks.""" + + @staticmethod + @patch_hook(DefaultRoom, "at_object_delete") + def at_object_delete(room, hook=None): + """ + Called just before the database object is permanently + delete()d from the database. If this method returns False, + deletion is aborted. + + """ + if not room.events.call("can_delete", room): + return False + + room.events.call("delete", room) + return True + ## Default events +# Character events +create_event_type(DefaultCharacter, "can_move", ["character", + "origin", "destination"], """ + 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()' function. + + 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. + """) +create_event_type(DefaultCharacter, "can_delete", ["character"], """ + 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. + """) +create_event_type(DefaultCharacter, "delete", ["character"], """ + 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. + """) +create_event_type(DefaultCharacter, "greet", ["character", "newcomer"], """ + 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. + """) +create_event_type(DefaultCharacter, "move", ["character", + "origin", "destination"], """ + 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. + """) +create_event_type(DefaultCharacter, "puppeted", ["character"], """ + 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. + """) +create_event_type(DefaultCharacter, "unpuppeted", ["character"], """ + 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. + """) + # Exit events create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], """ @@ -242,6 +440,47 @@ create_event_type(DefaultExit, "traverse", ["character", "exit", """) # Room events +create_event_type(DefaultRoom, "can_delete", ["room"], """ + 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. + """) +create_event_type(DefaultRoom, "can_move", ["character", "room"], """ + 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. + """) +create_event_type(DefaultRoom, "delete", ["room"], """ + 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. + """) +create_event_type(DefaultRoom, "move", ["character", + "origin", "destination"], """ + 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. + """) create_event_type(DefaultRoom, "time", ["room"], """ A repeated event to be called regularly. This event is scheduled to repeat at different times, specified From ae79ca96a7efd8206fd1d6903474a06c1e896acd Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 29 Mar 2017 11:36:04 -0700 Subject: [PATCH 079/133] Fix errors in unittests and conflicts with the events handler --- evennia/contrib/events/handler.py | 5 ++-- evennia/contrib/events/scripts.py | 2 +- evennia/contrib/events/tests.py | 4 +++ evennia/contrib/events/typeclasses.py | 37 ++++++++++++++++++++------- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/evennia/contrib/events/handler.py b/evennia/contrib/events/handler.py index 5a85623af..f6bd7ae98 100644 --- a/evennia/contrib/events/handler.py +++ b/evennia/contrib/events/handler.py @@ -151,14 +151,15 @@ class EventsHandler(object): Returns: True to report the event was called without interruption, - False otherwise. + False otherwise. If the EventHandler isn't found, return + None. """ handler = type(self).script if handler: return handler.call_event(self.obj, event_name, *args, **kwargs) - return False + return None @staticmethod def format_event(event): diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index a3b7d2e3b..18cd59b10 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -75,7 +75,7 @@ class EventHandler(DefaultScript): # Place the script in the EventsHandler Handler.script = self - DefaultObject.events = typeclasses.PatchedObject.events + DefaultObject.events = typeclasses.EventObject.events # Create the channel if non-existent try: diff --git a/evennia/contrib/events/tests.py b/evennia/contrib/events/tests.py index 0dd2bbf8f..09c5b5c94 100644 --- a/evennia/contrib/events/tests.py +++ b/evennia/contrib/events/tests.py @@ -13,6 +13,7 @@ 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 CmdEvent +from evennia.contrib.events.handler import EventsHandler # Force settings settings.EVENTS_CALENDAR = "standard" @@ -32,6 +33,7 @@ class TestEventHandler(EvenniaTest): def tearDown(self): """Stop the event handler.""" self.handler.stop() + EventsHandler.script = None super(TestEventHandler, self).tearDown() def test_start(self): @@ -265,6 +267,7 @@ class TestCmdEvent(CommandTest): db_typeclass_path="evennia.contrib.events.scripts.TimeEventScript"): script.stop() + EventsHandler.script = None super(TestCmdEvent, self).tearDown() def test_list(self): @@ -412,6 +415,7 @@ class TestDefaultEvents(CommandTest): def tearDown(self): """Stop the event handler.""" self.handler.stop() + EventsHandler.script = None super(TestDefaultEvents, self).tearDown() def test_exit(self): diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 4e1172513..1b5177de9 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -1,5 +1,16 @@ """ Patched typeclasses for Evennia. + +These typeclasses are not inherited from DefaultObject and other +Evennia default types. They softly "patch" some of these object hooks +however. While this adds a new layer in this module, it's (normally) +more simple to use from game designers, since it doesn't require a +new inheritance. These replaced hooks are only active if the event +system is active. You shouldn't need to change this module, just +override the hooks as you usually do in your custom typeclasses. +Calling super() would call the Default hooks (which would call the +event hook without further ado). + """ from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom @@ -9,7 +20,7 @@ from evennia.contrib.events.custom import ( create_event_type, patch_hook, create_time_event) from evennia.contrib.events.handler import EventsHandler -class PatchedCharacter: +class EventCharacter: """Patched typeclass for DefaultCharcter.""" @@ -86,6 +97,7 @@ class PatchedCharacter: origin = source_location destination = character.location + exits = [] if origin: exits = [o for o in destination.contents if o.location is destination and o.destination is origin] if exits: @@ -133,6 +145,9 @@ class PatchedCharacter: if can: can = origin.events.call("can_move", character, origin) + if can is None: + return True + return can return True @@ -212,13 +227,7 @@ class PatchedCharacter: hook(character) -class PatchedObject(object): - @lazy_property - def events(self): - """Return the EventsHandler.""" - return EventsHandler(self) - -class PatchedExit(object): +class EventExit(object): """Patched exit to patch some hooks of DefaultExit.""" @@ -254,7 +263,7 @@ class PatchedExit(object): exit, exit.location, exit.destination) -class PatchedRoom: +class EventRoom: """Soft-patching of room's default hooks.""" @@ -273,6 +282,16 @@ class PatchedRoom: room.events.call("delete", room) return True + +class EventObject(object): + + """Patched default object.""" + + @lazy_property + def events(self): + """Return the EventsHandler.""" + return EventsHandler(self) + ## Default events # Character events create_event_type(DefaultCharacter, "can_move", ["character", From 78ad2135d4dfa697e2fb1c01063c8e74fd2c97a4 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 29 Mar 2017 12:00:57 -0700 Subject: [PATCH 080/133] Add some character and room event types --- evennia/contrib/events/typeclasses.py | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 1b5177de9..5078ddaae 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -144,6 +144,13 @@ class EventCharacter: origin, destination) if can: can = origin.events.call("can_move", character, 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 character]: + can = present.events.call("can_part", present, character) + if not can: + break if can is None: return True @@ -209,6 +216,11 @@ class EventCharacter: hook(character) character.events.call("puppeted", character) + # Call the room's puppeted_in event + location = character.location + if location and isinstance(location, DefaultRoom): + location.events.call("puppeted_in", character, location) + @staticmethod @patch_hook(DefaultCharacter, "at_pre_unpuppet") def at_pre_unpuppet(character, hook=None): @@ -226,6 +238,11 @@ class EventCharacter: character.events.call("unpuppeted", character) hook(character) + # Call the room's unpuppeted_in event + location = character.location + if location and isinstance(location, DefaultRoom): + location.events.call("unpuppeted_in", character, location) + class EventExit(object): @@ -316,6 +333,19 @@ create_event_type(DefaultCharacter, "can_delete", ["character"], """ Variables you can use in this event: character: the character connected to this event. """) +create_event_type(DefaultCharacter, "can_part", ["character", "departing"], """ + 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. + """) create_event_type(DefaultCharacter, "delete", ["character"], """ Before deleting the character. This event is called just before deleting this character. It shouldn't @@ -500,6 +530,17 @@ create_event_type(DefaultRoom, "move", ["character", origin: the old location of the character. destination: the new location of the character. """) +create_event_type(DefaultRoom, "puppeted_in", ["character", "room"], """ + 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. + """) create_event_type(DefaultRoom, "time", ["room"], """ A repeated event to be called regularly. This event is scheduled to repeat at different times, specified @@ -517,3 +558,14 @@ create_event_type(DefaultRoom, "time", ["room"], """ Variables you can use in this event: room: the room connected to this event. """, create_time_event) +create_event_type(DefaultRoom, "unpuppeted_in", ["character", "room"], """ + 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. + """) From 69c5c2c5543109665557c9a556635c33b56aacd5 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 29 Mar 2017 14:23:09 -0700 Subject: [PATCH 081/133] Update the msg_leave and msg_arrive events to use the new hooks --- evennia/contrib/events/typeclasses.py | 80 ++++++++++++++++----------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 5078ddaae..7e1cffd6f 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -26,57 +26,73 @@ class EventCharacter: @staticmethod @patch_hook(DefaultCharacter, "announce_move_from") - def announce_move_from(character, destination, msg=None, hook=None): + def announce_move_from(character, destination, msg=None, mapping=None, + hook=None): """ Called if the move is to be announced. This is called while we are still standing in the old - location. Customizing the message through events is possible. + location. Args: destination (Object): The place we are going to. - msg (optional): a custom message to replace the default one. + 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 character.location: return - if msg: - string = msg - else: - string = "{character} is leaving {origin}, heading for {destination}." + string = msg or "{object} is leaving {origin}, heading for {destination}." # Get the exit from location to destination location = character.location exits = [o for o in location.contents if o.location is location and o.destination is destination] + mapping = mapping or {} + mapping.update({ + "character": character, + }) + if exits: exits[0].events.call("msg_leave", character, exits[0], - location, destination, string) + location, destination, string, mapping) string = exits[0].events.get_variable("message") - - mapping = { - "character": character, - "exit": exits[0] if exits else "somewhere", - "origin": location or "nowhere", - "destination": destination or "nowhere", - } + mapping = exits[0].events.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 - location.msg_contents(string, exclude=(character, ), mapping=mapping) + hook(character, destination, msg=string, mapping=mapping) @staticmethod @patch_hook(DefaultCharacter, "announce_move_to") - def announce_move_to(character, source_location, msg=None, hook=None): + def announce_move_to(character, source_location, msg=None, mapping=None, + hook=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 default message to be displayed. + 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. """ @@ -88,36 +104,32 @@ class EventCharacter: return if source_location: - if msg: - string = msg - else: - string = "{character} arrives to {destination} from {origin}." + string = msg or "{character} arrives to {destination} from {origin}." else: string = "{character} arrives to {destination}." origin = source_location destination = character.location exits = [] + mapping = mapping or {} + mapping.update({ + "character": character, + }) + if origin: exits = [o for o in destination.contents if o.location is destination and o.destination is origin] if exits: exits[0].events.call("msg_arrive", character, exits[0], - origin, destination, string) + origin, destination, string, mapping) string = exits[0].events.get_variable("message") - - mapping = { - "character": character, - "exit": exits[0] if exits else "somewhere", - "origin": origin or "nowhere", - "destination": destination or "nowhere", - } + mapping = exits[0].events.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 - destination.msg_contents(string, exclude=(character, ), mapping=mapping) + hook(character, source_location, msg=string, mapping=mapping) @staticmethod @patch_hook(DefaultCharacter, "at_before_move") @@ -412,7 +424,7 @@ create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], room: the room in which stands the character before moving. """) create_event_type(DefaultExit, "msg_arrive", ["character", "exit", - "origin", "destination", "message"], """ + "origin", "destination", "message", "mapping"], """ 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 @@ -432,9 +444,10 @@ create_event_type(DefaultExit, "msg_arrive", ["character", "exit", 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. """) create_event_type(DefaultExit, "msg_leave", ["character", "exit", - "origin", "destination", "message"], """ + "origin", "destination", "message", "mapping"], """ 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 @@ -455,6 +468,7 @@ create_event_type(DefaultExit, "msg_leave", ["character", "exit", 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. """) create_event_type(DefaultExit, "time", ["exit"], """ A repeated event to be called regularly. From 1caf5e988cb953deca12ec024bb475294695be61 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 29 Mar 2017 17:05:28 -0700 Subject: [PATCH 082/133] Add new events for objects --- evennia/contrib/events/custom.py | 22 +++++ evennia/contrib/events/scripts.py | 10 +- evennia/contrib/events/typeclasses.py | 130 ++++++++++++++++++++++++-- 3 files changed, 151 insertions(+), 11 deletions(-) diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index f39e1a996..53071f36c 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -58,6 +58,28 @@ def create_event_type(typeclass, event_name, variables, help_text, event_types.append((typeclass_name, event_name, variables, help_text, custom_add, custom_call)) +def invalidate_event_type(typeclass, event_name): + """ + Invalidate a descending event type defined above in the hierarchy. + + Event types follow the hierarchy of inheritance. Events defined + in DefaultObjects would be accessible in DefaultRooms, for instance. + This can ensure that the event is limited and doesn't apply to + children with instances. + + Args: + typeclass (type): the class describing the typeclass. + event_name (str): the name of the event to invalidate. + + Example: + create_event_type(DefaultObject, "get", ["object"], "Someone gets.") + invalidate_event_type(DefaultRoom, "get") + # room objects won't have the 'get' event + + """ + typeclass_name = typeclass.__module__ + "." + typeclass.__name__ + event_types.append((typeclass_name, event_name, None, "", None, None)) + def patch_hook(typeclass, method_name): """ Decorator to softly patch a hook in a typeclass. diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 18cd59b10..839381949 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -135,10 +135,18 @@ class EventHandler(DefaultScript): event_types = self.ndb.event_types classes = Queue() classes.put(type(obj)) + invalid = [] while not classes.empty(): typeclass = classes.get() typeclass_name = typeclass.__module__ + "." + typeclass.__name__ - types.update(event_types.get(typeclass_name, {})) + for key, etype in event_types.get(typeclass_name, {}).items(): + if key in invalid: + continue + if etype[0] is None: # Invalidate + invalid.append(key) + continue + if key not in types: + types[key] = etype # Look for the parent classes for parent in typeclass.__bases__: diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 7e1cffd6f..b9ca7961b 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -17,7 +17,7 @@ from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom from evennia import ScriptDB from evennia.utils.utils import inherits_from, lazy_property from evennia.contrib.events.custom import ( - create_event_type, patch_hook, create_time_event) + create_event_type, invalidate_event_type, patch_hook, create_time_event) from evennia.contrib.events.handler import EventsHandler class EventCharacter: @@ -71,7 +71,8 @@ class EventCharacter: if not string: return - hook(character, destination, msg=string, mapping=mapping) + if hook: + hook(character, destination, msg=string, mapping=mapping) @staticmethod @patch_hook(DefaultCharacter, "announce_move_to") @@ -129,7 +130,8 @@ class EventCharacter: if not string: return - hook(character, source_location, msg=string, mapping=mapping) + if hook: + hook(character, source_location, msg=string, mapping=mapping) @staticmethod @patch_hook(DefaultCharacter, "at_before_move") @@ -183,7 +185,9 @@ class EventCharacter: source_location (Object): Wwhere we came from. This may be `None`. """ - hook(character, source_location) + if hook: + hook(character, source_location) + origin = source_location destination = character.location Room = DefaultRoom @@ -225,7 +229,9 @@ class EventCharacter: puppeting this Object. """ - hook(character) + if hook: + hook(character) + character.events.call("puppeted", character) # Call the room's puppeted_in event @@ -248,7 +254,9 @@ class EventCharacter: """ character.events.call("unpuppeted", character) - hook(character) + + if hook: + hook(character) # Call the room's unpuppeted_in event location = character.location @@ -284,7 +292,8 @@ class EventExit(object): if not allow: return - hook(exit, traversing_object, target_location) + if hook: + hook(exit, traversing_object, target_location) # After traversing if is_character: @@ -320,6 +329,45 @@ class EventObject(object): def events(self): """Return the EventsHandler.""" return EventsHandler(self) + @staticmethod + @patch_hook(DefaultObject, "at_get") + def at_get(obj, getter, hook=None): + """ + 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. + + """ + if hook: + hook(obj, getter) + + obj.events.call("get", getter, obj) + + @staticmethod + @patch_hook(DefaultObject, "at_drop") + def at_drop(obj, dropper, hook=None): + """ + 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. + + """ + if hook: + hook(obj, dropper) + + obj.events.call("drop", dropper, obj) ## Default events # Character events @@ -368,6 +416,8 @@ create_event_type(DefaultCharacter, "delete", ["character"], """ Variables you can use in this event: character: the character connected to this event. """) +invalidate_event_type(DefaultCharacter, "drop") +invalidate_event_type(DefaultCharacter, "get") create_event_type(DefaultCharacter, "greet", ["character", "newcomer"], """ A new character arrives in the location of this character. This event is called when another character arrives in the location @@ -400,6 +450,23 @@ create_event_type(DefaultCharacter, "puppeted", ["character"], """ Variables you can use in this event: character: the character connected to this event. """) +create_event_type(DefaultCharacter, "time", ["character"], """ + 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. + """, create_time_event) create_event_type(DefaultCharacter, "unpuppeted", ["character"], """ When the character is about to be un-puppeted. This event is called when a player is about to un-puppet the @@ -410,6 +477,45 @@ create_event_type(DefaultCharacter, "unpuppeted", ["character"], """ character: the character connected to this event. """) +# Object events +create_event_type(DefaultObject, "drop", ["character", "obj"], """ + 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. + """) +create_event_type(DefaultObject, "get", ["character", "obj"], """ + 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. + """) +create_event_type(DefaultObject, "time", ["object"], """ + 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. + """, create_time_event) + # Exit events create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], """ @@ -422,7 +528,9 @@ create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], 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. -""") + """) +invalidate_event_type(DefaultExit, "drop") +invalidate_event_type(DefaultExit, "get") create_event_type(DefaultExit, "msg_arrive", ["character", "exit", "origin", "destination", "message", "mapping"], """ Customize the message when a character arrives through this exit. @@ -445,7 +553,7 @@ create_event_type(DefaultExit, "msg_arrive", ["character", "exit", 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. -""") + """) create_event_type(DefaultExit, "msg_leave", ["character", "exit", "origin", "destination", "message", "mapping"], """ Customize the message when a character leaves through this exit. @@ -469,7 +577,7 @@ create_event_type(DefaultExit, "msg_leave", ["character", "exit", destination: the destination of the character. message: the message to be displayed in the location. mapping: a dictionary containing additional mapping. -""") + """) create_event_type(DefaultExit, "time", ["exit"], """ A repeated event to be called regularly. This event is scheduled to repeat at different times, specified @@ -533,6 +641,8 @@ create_event_type(DefaultRoom, "delete", ["room"], """ Variables you can use in this event: room: the room connected to this event. """) +invalidate_event_type(DefaultRoom, "drop") +invalidate_event_type(DefaultRoom, "get") create_event_type(DefaultRoom, "move", ["character", "origin", "destination"], """ After the character has moved into this room. From 08fd37aa9857009f18849144b6dff9ea20c4d440 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 3 Apr 2017 14:54:54 -0700 Subject: [PATCH 083/133] Update event typeclasses to use inheritance instead of hook patching --- evennia/contrib/events/custom.py | 74 ++++--- evennia/contrib/events/scripts.py | 19 +- evennia/contrib/events/tests.py | 34 ++- evennia/contrib/events/typeclasses.py | 288 ++++++++++++++++---------- 4 files changed, 258 insertions(+), 157 deletions(-) diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index 53071f36c..a4cef9a6e 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -80,44 +80,6 @@ def invalidate_event_type(typeclass, event_name): typeclass_name = typeclass.__module__ + "." + typeclass.__name__ event_types.append((typeclass_name, event_name, None, "", None, None)) -def patch_hook(typeclass, method_name): - """ - Decorator to softly patch a hook in a typeclass. - - This decorator should not be used, unless for good reasons, outside - of this contrib. The advantage of using decorated soft patchs is - in allowing users to customize typeclasses without changing the - inheritance tree for a couple of methods. - - """ - hook = getattr(typeclass, method_name, None) - def wrapper(method): - """Wrapper around the hook.""" - def overridden_hook(*args, **kwargs): - """Function to call the new hook.""" - # Enforce the old hook as a keyword argument - kwargs["hook"] = hook - ret = method(*args, **kwargs) - return ret - hooks.append((typeclass, method_name, overridden_hook)) - return overridden_hook - return wrapper - -def patch_hooks(): - """ - Patch all the configured hooks. - - This function should be called only once when the event system - has loaded, is set and has defined its patched typeclasses. - It will be called internally by the event system, you shouldn't - call this function in your game. - - """ - while hooks: - typeclass, method_name, new_hook = hooks[0] - setattr(typeclass, method_name, new_hook) - del hooks[0] - def connect_event_types(): """ Connect the event types when the script runs. @@ -243,7 +205,7 @@ def create_time_event(obj, event_name, number, parameters): def keyword_event(events, parameters): """ - Custom call for events with keywords (like say, or push, or pull, or turn...). + Custom call for events with keywords (like push, or pull, or turn...). This function should be imported and added as a custom_call parameter to add the event type when the event supports keywords @@ -267,3 +229,37 @@ def keyword_event(events, parameters): to_call.append(event) return to_call + +def phrase_event(events, parameters): + """ + Custom call for events with keywords in sentences (like say or whisper). + + This function should be imported and added as a custom_call + parameter to add the event type when the event supports keywords + in phrase as parameters. Keywords in parameters are one or more + words separated by a comma. For instance, a 'say yes, okay' event + can be set to trigger when the player says something containing + either "yes" or "okay" (maybe 'say I don't like it, but okay'). + + Args: + events (list of dict): the list of events to be called. + parameters (str): the actual parameters entered to trigger the event. + + Returns: + A list containing the event dictionaries to be called. + + """ + 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 event in events: + keys = event["parameters"] + if not keys or any(key.strip().lower() in words for key in keys.split(",")): + to_call.append(event) + + return to_call diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 839381949..d46909c33 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -14,8 +14,7 @@ from evennia import logger from evennia.utils.create import create_channel from evennia.utils.dbserialize import dbserialize from evennia.utils.utils import all_from_module, delay -from evennia.contrib.events.custom import ( - connect_event_types, get_next_wait, patch_hooks) +from evennia.contrib.events.custom import connect_event_types, get_next_wait from evennia.contrib.events.exceptions import InterruptEvent from evennia.contrib.events.handler import EventsHandler as Handler from evennia.contrib.events import typeclasses @@ -36,6 +35,7 @@ class EventHandler(DefaultScript): """ def at_script_creation(self): + """Hook called when the script is created.""" self.key = "event_handler" self.desc = "Global event handler" self.persistent = True @@ -50,10 +50,21 @@ class EventHandler(DefaultScript): self.db.tasks = {} def at_start(self): - """Set up the event system.""" + """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: + + - Refresh and re-connect event types. + - Generate locals (individual events' namespace). + - Load event helpers, including user-defined ones. + - Re-schedule tasks that aren't set to fire anymore. + - Effectively connect the handler to the main script. + + """ self.ndb.event_types = {} connect_event_types() - patch_hooks() # Generate locals self.ndb.current_locals = {} diff --git a/evennia/contrib/events/tests.py b/evennia/contrib/events/tests.py index 09c5b5c94..f5b1a40f5 100644 --- a/evennia/contrib/events/tests.py +++ b/evennia/contrib/events/tests.py @@ -30,6 +30,13 @@ class TestEventHandler(EvenniaTest): self.handler = create_script( "evennia.contrib.events.scripts.EventHandler") + # 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.""" self.handler.stop() @@ -225,28 +232,28 @@ class TestEventHandler(EvenniaTest): self.assertIsNotNone(self.char1.events) # Add an event - event = self.room1.events.add("say", "pass", author=self.char1, + event = self.room1.events.add("dummy", "pass", author=self.char1, valid=True) self.assertEqual(event.obj, self.room1) - self.assertEqual(event.name, "say") + self.assertEqual(event.name, "dummy") self.assertEqual(event.code, "pass") self.assertEqual(event.author, self.char1) self.assertEqual(event.valid, True) self.assertIn([event], self.room1.events.all().values()) # Edit this very event - new = self.room1.events.edit("say", 0, "character.db.say = True", + new = self.room1.events.edit("dummy", 0, "character.db.say = True", author=self.char1, valid=True) self.assertIn([new], self.room1.events.all().values()) self.assertNotIn([event], self.room1.events.all().values()) # Try to call this event - self.assertTrue(self.room1.events.call("say", + self.assertTrue(self.room1.events.call("dummy", locals={"character": self.char2})) self.assertTrue(self.char2.db.say) # Delete the event - self.room1.events.remove("say", 0) + self.room1.events.remove("dummy", 0) self.assertEqual(self.room1.events.all(), {}) @@ -260,6 +267,13 @@ class TestCmdEvent(CommandTest): self.handler = create_script( "evennia.contrib.events.scripts.EventHandler") + # 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.""" self.handler.stop() @@ -412,6 +426,13 @@ class TestDefaultEvents(CommandTest): self.handler = create_script( "evennia.contrib.events.scripts.EventHandler") + # 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.""" self.handler.stop() @@ -428,6 +449,9 @@ class TestDefaultEvents(CommandTest): 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 event self.handler.add_event(self.exit, "can_traverse", code, author=self.char1, valid=True) diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index b9ca7961b..f6b372419 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -1,33 +1,23 @@ """ -Patched typeclasses for Evennia. +Typeclasses for the event system. -These typeclasses are not inherited from DefaultObject and other -Evennia default types. They softly "patch" some of these object hooks -however. While this adds a new layer in this module, it's (normally) -more simple to use from game designers, since it doesn't require a -new inheritance. These replaced hooks are only active if the event -system is active. You shouldn't need to change this module, just -override the hooks as you usually do in your custom typeclasses. -Calling super() would call the Default hooks (which would call the -event hook without further ado). +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 inherits_from, lazy_property +from evennia.utils.utils import delay, inherits_from, lazy_property from evennia.contrib.events.custom import ( - create_event_type, invalidate_event_type, patch_hook, create_time_event) + create_event_type, invalidate_event_type, create_time_event, phrase_event) from evennia.contrib.events.handler import EventsHandler -class EventCharacter: +class EventCharacter(DefaultCharacter): - """Patched typeclass for DefaultCharcter.""" + """Typeclass to represent a character and call event types.""" - @staticmethod - @patch_hook(DefaultCharacter, "announce_move_from") - def announce_move_from(character, destination, msg=None, mapping=None, - hook=None): + 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 @@ -47,21 +37,21 @@ class EventCharacter: destination: the location of the object after moving. """ - if not character.location: + if not self.location: return string = msg or "{object} is leaving {origin}, heading for {destination}." # Get the exit from location to destination - location = character.location + 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": character, + "character": self, }) if exits: - exits[0].events.call("msg_leave", character, exits[0], + exits[0].events.call("msg_leave", self, exits[0], location, destination, string, mapping) string = exits[0].events.get_variable("message") mapping = exits[0].events.get_variable("mapping") @@ -71,13 +61,9 @@ class EventCharacter: if not string: return - if hook: - hook(character, destination, msg=string, mapping=mapping) + super(EventCharacter, self).announce_move_from(destination, msg=string, mapping=mapping) - @staticmethod - @patch_hook(DefaultCharacter, "announce_move_to") - def announce_move_to(character, source_location, msg=None, mapping=None, - hook=None): + 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. @@ -97,11 +83,11 @@ class EventCharacter: """ - if not source_location and character.location.has_player: + 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) - character.location.msg(string) + self.location.msg(string) return if source_location: @@ -110,17 +96,17 @@ class EventCharacter: string = "{character} arrives to {destination}." origin = source_location - destination = character.location + destination = self.location exits = [] mapping = mapping or {} mapping.update({ - "character": character, + "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].events.call("msg_arrive", character, exits[0], + exits[0].events.call("msg_arrive", self, exits[0], origin, destination, string, mapping) string = exits[0].events.get_variable("message") mapping = exits[0].events.get_variable("mapping") @@ -130,12 +116,9 @@ class EventCharacter: if not string: return - if hook: - hook(character, source_location, msg=string, mapping=mapping) + super(EventCharacter, self).announce_move_to(source_location, msg=string, mapping=mapping) - @staticmethod - @patch_hook(DefaultCharacter, "at_before_move") - def at_before_move(character, destination, hook=None): + def at_before_move(self, destination): """ Called just before starting to move this object to destination. @@ -151,18 +134,18 @@ class EventCharacter: before it is even started. """ - origin = character.location + origin = self.location Room = DefaultRoom if isinstance(origin, Room) and isinstance(destination, Room): - can = character.events.call("can_move", character, + can = self.events.call("can_move", self, origin, destination) if can: - can = origin.events.call("can_move", character, origin) + can = origin.events.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 character]: - can = present.events.call("can_part", present, character) + o, DefaultCharacter) and o is not self]: + can = present.events.call("can_part", present, self) if not can: break @@ -173,9 +156,7 @@ class EventCharacter: return True - @staticmethod - @patch_hook(DefaultCharacter, "at_after_move") - def at_after_move(character, source_location, hook=None): + 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 @@ -185,39 +166,34 @@ class EventCharacter: source_location (Object): Wwhere we came from. This may be `None`. """ - if hook: - hook(character, source_location) + super(EventCharacter, self).at_after_move(source_location) origin = source_location - destination = character.location + destination = self.location Room = DefaultRoom if isinstance(origin, Room) and isinstance(destination, Room): - character.events.call("move", character, origin, destination) - destination.events.call("move", character, origin, destination) + self.events.call("move", self, origin, destination) + destination.events.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)]: - present.events.call("greet", present, character) + o, DefaultCharacter) and o is not self]: + present.events.call("greet", present, self) - @staticmethod - @patch_hook(DefaultCharacter, "at_object_delete") - def at_object_delete(character, hook=None): + 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 character.events.call("can_delete", character): + if not self.events.call("can_delete", self): return False - character.events.call("delete", character) + self.events.call("delete", self) return True - @staticmethod - @patch_hook(DefaultCharacter, "at_post_puppet") - def at_post_puppet(character, hook=None): + def at_post_puppet(self): """ Called just after puppeting has been completed and all Player<->Object links have been established. @@ -229,19 +205,16 @@ class EventCharacter: puppeting this Object. """ - if hook: - hook(character) + super(EventCharacter, self).at_post_puppet() - character.events.call("puppeted", character) + self.events.call("puppeted", self) # Call the room's puppeted_in event - location = character.location + location = self.location if location and isinstance(location, DefaultRoom): - location.events.call("puppeted_in", character, location) + location.events.call("puppeted_in", self, location) - @staticmethod - @patch_hook(DefaultCharacter, "at_pre_unpuppet") - def at_pre_unpuppet(character, hook=None): + def at_pre_unpuppet(self): """ Called just before beginning to un-connect a puppeting from this Player. @@ -253,24 +226,21 @@ class EventCharacter: puppeting this Object. """ - character.events.call("unpuppeted", character) - - if hook: - hook(character) + self.events.call("unpuppeted", self) # Call the room's unpuppeted_in event - location = character.location + location = self.location if location and isinstance(location, DefaultRoom): - location.events.call("unpuppeted_in", character, location) + location.events.call("unpuppeted_in", self, location) + + super(EventCharacter, self).at_pre_unpuppet() -class EventExit(object): +class EventExit(DefaultExit): - """Patched exit to patch some hooks of DefaultExit.""" + """Modified exit including management of events.""" - @staticmethod - @patch_hook(DefaultExit, "at_traverse") - def at_traverse(exit, traversing_object, target_location, hook=None): + def at_traverse(self, traversing_object, target_location): """ This hook is responsible for handling the actual traversal, normally by calling @@ -287,51 +257,91 @@ class EventExit(object): """ is_character = inherits_from(traversing_object, DefaultCharacter) if is_character: - allow = exit.events.call("can_traverse", traversing_object, - exit, exit.location) + allow = self.events.call("can_traverse", traversing_object, + self, self.location) if not allow: return - if hook: - hook(exit, traversing_object, target_location) + super(EventExit, self).at_traverse(traversing_object, target_location) # After traversing if is_character: - exit.events.call("traverse", traversing_object, - exit, exit.location, exit.destination) + self.events.call("traverse", traversing_object, + self, self.location, self.destination) -class EventRoom: +class EventRoom(DefaultRoom): - """Soft-patching of room's default hooks.""" + """Default room with management of events.""" - @staticmethod - @patch_hook(DefaultRoom, "at_object_delete") - def at_object_delete(room, hook=None): + 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 room.events.call("can_delete", room): + if not self.events.call("can_delete", self): return False - room.events.call("delete", room) + self.events.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. -class EventObject(object): + Args: + speaker (Object): The object speaking. + message (str): The words spoken. - """Patched default object.""" + 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.events.call("can_say", speaker, self, message, + parameters=message) + if not allow: + return + + message = self.events.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.events.call("can_say", speaker, present, + message, parameters=message) + if not allow: + return + + message = present.events.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.events.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.events.call, "say", speaker, present, message, + parameters=message) + + return message + + +class EventObject(DefaultObject): + + """Default object with management of events.""" @lazy_property def events(self): """Return the EventsHandler.""" return EventsHandler(self) - @staticmethod - @patch_hook(DefaultObject, "at_get") - def at_get(obj, getter, hook=None): + + def at_get(self, getter): """ Called by the default `get` command when this object has been picked up. @@ -344,14 +354,10 @@ class EventObject(object): permissions for that. """ - if hook: - hook(obj, getter) + super(EventObject, self).at_get(getter) + self.events.call("get", getter, self) - obj.events.call("get", getter, obj) - - @staticmethod - @patch_hook(DefaultObject, "at_drop") - def at_drop(obj, dropper, hook=None): + def at_drop(self, dropper): """ Called by the default `drop` command when this object has been dropped. @@ -364,10 +370,8 @@ class EventObject(object): permissions from that. """ - if hook: - hook(obj, dropper) - - obj.events.call("drop", dropper, obj) + super(EventObject, self).at_drop(dropper) + self.events.call("drop", dropper, self) ## Default events # Character events @@ -406,6 +410,19 @@ create_event_type(DefaultCharacter, "can_part", ["character", "departing"], """ departing: the character who wants to leave this room. character: the character connected to this event. """) +create_event_type(DefaultCharacter, "can_say", ["speaker", "character", "message"], """ + 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. + """, custom_call=phrase_event) create_event_type(DefaultCharacter, "delete", ["character"], """ Before deleting the character. This event is called just before deleting this character. It shouldn't @@ -450,6 +467,27 @@ create_event_type(DefaultCharacter, "puppeted", ["character"], """ Variables you can use in this event: character: the character connected to this event. """) +create_event_type(DefaultCharacter, "say", ["speaker", "character", "message"], """ + 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. + """, custom_call=phrase_event) create_event_type(DefaultCharacter, "time", ["character"], """ A repeated event to be called regularly. This event is scheduled to repeat at different times, specified @@ -631,6 +669,19 @@ create_event_type(DefaultRoom, "can_move", ["character", "room"], """ character: the character who wants to move in this room. room: the room connected to this event. """) +create_event_type(DefaultRoom, "can_say", ["character", "room", "message"], """ + 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. + """, custom_call=phrase_event) create_event_type(DefaultRoom, "delete", ["room"], """ Before deleting the room. This event is called just before deleting this room. It shouldn't @@ -665,6 +716,25 @@ create_event_type(DefaultRoom, "puppeted_in", ["character", "room"], """ character: the character who have just been puppeted in this room. room: the room connected to this event. """) +create_event_type(DefaultRoom, "say", ["character", "room", "message"], """ + 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. + """, custom_call=phrase_event) create_event_type(DefaultRoom, "time", ["room"], """ A repeated event to be called regularly. This event is scheduled to repeat at different times, specified From 17b546c0415fc9085237ffb97bec154e2a5a7161 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 3 Apr 2017 21:31:44 -0700 Subject: [PATCH 084/133] Update the README file, merging documents --- evennia/contrib/events/README.md | 576 +++++++++++++++++++++++++----- evennia/contrib/events/helpers.py | 2 +- 2 files changed, 494 insertions(+), 84 deletions(-) diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md index e23a3765c..53c9c510e 100644 --- a/evennia/contrib/events/README.md +++ b/evennia/contrib/events/README.md @@ -2,27 +2,59 @@ 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 trusted builders) could have a specific room, exit, character, object or something else behaves 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. +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 two important questions, and answer them for yourself, before deciding to use this system in your game: -1. Is it worth it? This event system isn't some magical feature that would remove the need for the MU*'s development, and empower immortals to create fabulous things without any control. Everything that immortals would be able to do through the event system could be achieved by modifying the source code. Immortals might be familiar with Evennia's design, and could contribute by sending pull requests to your code, for instance. The event system could admittedly earn you time and have immortals (or trusted builders) adding in special features without the need for complex code. You have to consider, however, if it's worth adding this system in your game. A possible risk is that your immortals will try to do everything though this system and your code will not be updated, while there will still be room to enhance it. -2. Is it safe? Having arbitrary Python code running cannot be considered a secure feature. You will have to be extremely careful in deciding who can use this system. By default, immortals can create and edit events (these users have access to the `@py` command, which is potentially as dangerous). Builders will not be able to add or edit events, although you can change this setting, to have builders be able to create events, and set their events to require approval by an administrator. You can change permissions (see below for more details on how to do it). You are free to trust or mistrust your builders or other users, just remember that the potential for malign code cannot be restricted. +1. Is it worth it? This event system isn't some magical feature that would remove the need for the MU*'s development, and empower immortals to create fabulous things without any control. Everything that immortals would be able to do through the event system could be achieved by modifying the source code. Immortals might be familiar with Evennia's design, and could contribute by sending pull requests to your code, for instance. The event system could admittedly earn you time and have immortals adding in special features without the need for complex code. You have to consider, however, if it's worth adding this system in your game. A possible risk is that your immortals will try to do everything though this system and your code will not be updated, while there will still be room to enhance it. +2. Who should use this system? Having arbitrary Python code running cannot be considered a secure feature. You will have to be extremely careful in deciding who can use this system. By default, immortals can create and edit events (these users have access to the `@py` command, which is potentially as dangerous). Builders will not be able to add or edit events, although you can change this setting, to have builders be able to create events, and set their events to require approval by an administrator. You can change permissions (see below for more details on how to do it). You are free to trust or mistrust your builders or other users, just remember that the potential for malign code cannot be restricted. + +## Basic structure and vocabulary + +- At the basis of the event system are **event types**. An **event type** defines the context in which we would like to call some arbitrary code. For instance, one event type is defined on exits and will fire every time a character traverses through this exit. Event types 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 type. +- An event type should specify a **trigger**, a simple name describing the moment when the event type will be fired. The event type that will be fired every time a character traverses through an exit is called "traverse". Both "event types" and "trigger" can describe the same thing, although the term **trigger** in the rest of the documentation will be used to describe the moment when the event fires. Users of the system will be more interested in knowing what triggers are available for such and such objects, while developers will be there to create event types. +- Individual events can be set on individual objects. They contain the code that will be executed at a specific moment (when a specific action triggers this event type). More than one event can be connected to an object's event type: for instance, several events can be set on the "traverse" event type of a single exit. They will all be called in the order they have been defined. + +To see the system in context, when an object is picked up (using the default `get` command), a specific event type is fired: + +1. The event type "get" is set on objects (on the `DefaultObject` 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 type on this object. +4. All events tied to this object's "get" trigger will be executed in order. These events act as functions containing Python code that you can write, using specific variables that will be listed when you edit the event itself. +5. In individual events, 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 an event "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 three steps: +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: the event system is contained in a general script that holds all data. It has the advantage of saving nothing in your objects, and you can decide to turn it on and off fairly easily. In order to turn events on, you need to activate the script. Once executed, the script will remain, including after server reset or reload: +1. Launch the main script: ```@py ev.create_script("evennia.contrib.events.scripts.EventHandler")``` -2. Set the permissions: the event system uses some custom permissions that you can set to define who is allowed to do what, and to what extent (see below for details). Most of these settings will be stored in your setting file (`server/conf/settings.py`): +2. Set the permissions (optional): - `EVENTS_WITH_VALIDATION`: a group that can edit events, but will need approval (default to `None`). - `EVENTS_WITHOUT_VALIDATION`: a group with permission to edit events without need of validation (default to `"immortals"`). - `EVENTS_VALIDATING`: a group that can validate events (default to `"immortals"`). - `EVENTS_CALENDAR`: type of the calendar to be used (either `None`, `"standard"`, `"custom"` or a custom callback, default to `None`). -3. Adding the `@event` command: finally, you will need to add the `@event` command to your Character CmdSet. As with the two previous steps, this is to be done only once: you can disable the event system without removing the `@event` command (a section will describe how useful it can be in case of errors). +3. Add the `@event` 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. ### Starting the event script @@ -30,21 +62,19 @@ To start the event script, you only need a single command, using `@py`. @py ev.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, event description and so on. You may access it directly, but you will probably use the custom helper functions (see the section on extending the event system). +This command will create a global script (that is, a script independent from any object). This script will hold basic configuration, event description and so on. You may access it directly, but you will probably use the custom helper functions (see the section on extending the event system). Doing so will also create a `events` handler on all objects (see below for details). ### Editing permissions -This contrib is installed with default permissions. They define who can edit events without validation, and who can edit events but needs validation. Validation is a process in which an administrator (or somebody trusted as such) will check the events produced by others and will accept or reject them. If accepted, the events are connected, otherwise they are never run. +This contrib comes with its own set of permissions. They define who can edit events without validation, and who can edit events but needs validation. Validation is a process in which an administrator (or somebody trusted as such) will check the events produced by others and will accept or reject them. If accepted, the events are connected, otherwise they are never run. -By default, events can only be created by immortals. They don't need to be validated by anyone, after all, immortals also have access to the `@py` command, so they are probably trusted to use it wisely and not to run dangerous code on your server. - -That's the default configuration: no one except the immortals can edit events, and immortals don't need validation. It can easily be changed, either through settings or dynamically by changing permissions of users. +By default, events can only be created by immortals: no one except the immortals can edit events, and immortals don't need validation. It can easily be changed, either through settings or dynamically by changing permissions of users. #### Permissions in settings -The events contrib adds three 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: +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 group that can edit events, but will need approval. If you set this to "wizards", for instance, users with the permission "wizards" will be able to edit events. These events will not be connected, though, and will need to be checked and approved by an administrator. This setting can contain `None`, meaning that no group is allowed to edit events with validation. +- `EVENTS_WITH_VALIDATION`: this defines a permission that can edit events, but will need approval. If you set this to `"wizards"`, for instance, users with the permission `"wizards"` will be able to edit events. These events 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 events with validation. - `EVENTS_WITHOUT_VALIDATION`: this setting defines a permission allowing editing of events without needing validation. By default, this setting is set to `"immortals"`. It means that immortals can edit events, and they will be connected when they leave the editor, without needing approval. - `EVENTS_VALIDATING`: this last setting defines who can validate events. By default, this is set to `"immortals"`, meaning only immortals can see events needing validation, accept or reject them. @@ -65,20 +95,18 @@ This set of settings means that: 2. Immortals can edit events, their work doesn't need to be approved. It is automatically accepted and connected. 3. Immortals can also see events that need approval (these produced by wizards) and accept or reject them. Whenever accepted, the event is connected and will fire without constraint whenever it has to. -If you have an active staff of immortals, or are yourself sufficiently active on your project and have some contributors, you might decide to grant the privilege to write events **with** validation to builders, for instance (wizards, as the above permission, will automatically be included). It is recommended not to give contributors the right to edit events without validation unless you know, for a fact, that you can trust them. Remember, events have the potential to do many things... including freeze or crash your server... and potentially worse. - 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` contrib to schedule events. +- `"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. - A special callback to schedule time-related events in a way not supported by the `gametime` utility and the `custom_gametime` contrib (see below). #### Permissions on individual users -Sometimes, you have learned to know a contributor and wish to give him or her more privilege without upgrading him/her to a new group. For instance, there's a wizard that you have known for years: you don't know him/her well enough to promote him/her as an immortal, but you are sure he/she won't use the event system with harmful intents. You can give permissions to individual players through the `@perm` command, not altering their group (and then, not giving them extra commands), but allowing them to create events without validation. There are two permissions you can give to individual users: +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 events but not require validation before they are connected. If you do this on an individual basis, keep in mind the power granted to this user and carefully consider the potential impacts on your game or machine. -- `events_validating`: this permission allows this user to run validation checks on events needing to be validated. In practice, you shouldn't have to use this last permission, if you trust a user enough to run that path, perhaps he/she could be trusted with immortal permissions. +- `events_without_validation`: this would give this user the rights to edit events but not require validation before they are connected. +- `events_validating`: this permission allows this user to run validation checks on events needing to be validated. For instance, to give the right to edit events without needing approval to the player 'kaldara', you might do something like: @@ -92,7 +120,7 @@ The rights to use the `@event` command are directly related to these permissions ### Adding the `@event` command -You also have to add the `@event` command to your Character CmdSet. In your `commands/default_cmdsets`, you might have something like: +You also have to add the `@event` command to your Character CmdSet. In your `commands/default_cmdsets`, it might look like this: ```python from evennia import default_cmds @@ -114,98 +142,488 @@ class CharacterCmdSet(default_cmds.CharacterCmdSet): self.add(CmdEvent()) ``` -## Extending events +### Changing parent classes of typeclasses -This section will explain how to add new helper functions and events. +Finally, to use the event system, you need to have your typeclasses inherit from the modified event typeclasses. 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 `@event` command + +The event system relies, to a great extent, on its `@event` command. Who can execute this command, and who can do what with it, will depend on your set of permissions. + +The event system can be used on most Evennia objects, mostly typeclassed objects (excluding players). The first argument of the `@event` command is the name of the object you want to edit. It can also be used to know what event types are available for this specific object. + +### Examining events and event types + +To see the event types connected to an object, use the `@event` command and give the name or ID of the object to examine. For instance, @event here` to examine the event types on your current location. Or `@event self` to see the event types on yourself. + +This command will display a table, containing: + +- The name of each event type (trigger) in the first column. +- The number of events of this name, and the number of total lines of these events in the second column. +- A short help to tell you when the event is triggered in the third column. + +If you execute `@event #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 event + +The `/add` switch should be used to add an event. It takes two arguments beyond the object's name/DBREF: + +1. After an = sign, the trigger of the event to be edited (if not supplied, will display the list of possible triggers, like above). +2. The parameters (optional). + +We'll see events with parameters later. For the time being, let's try to prevent a character from going through the "north" exit of this room: + +``` +@event 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 trigger for us would be "can_traverse". + +> Why not "traverse"? If you read the description of both triggers, 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: + + @event/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() function 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 [helpers](#the-helper-functions) will elaborate on the `deny()` function and other helpers. 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 `@event/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 event. + +If you enter `@event north`, you should see that "can_traverse" now has an active event. You can use `@event north = can_traverse` to see more details on the connected events: + +``` +@event north = can_traverse ++--------------+--------------+----------------+--------------+--------------+ +| Number | Author | Updated | Param | Valid | ++~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+ +| 1 | XXXXX | 5 seconds ago | | Yes | ++--------------+--------------+----------------+--------------+--------------+ +``` + +The left column contains event numbers. You can use them to have even more information on a specific event. Here, for instance: + +``` +@event north = can_traverse 1 +Event 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 event is connected and active. +Event 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 an event + +You can use the `/edit` switch to the `@event` command to edit an event. 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 events are connected at this location. + +You can type `@event/edit = ` to see the events that are linked at this location. If there is only one event, it will be opened in the editor; if more are defined, you will be asked for a number to provide (for instance, `@event/edit north = can_traverse 2`). + +The command `@event` also provides a `/del` switch to remove an event. It takes the same arguments as the `/edit` switch. + +When removed, events are logged, so an administrator can retrieve its content, assuming the `/del` was an error. + +### The code editor + +When adding or editing an event, 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 helper functions + +In order to make development a little easier, the event system provides helper functions to be used in events themselves. You don't have to use them, they are just shortcuts. + +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 event 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` helper 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 helper in action. + +#### call_event + +Some events will call others. It is particularly useful for [chained events](#chained-events) that are described in a dedicated section. This helper 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 events + +In the Python code you will enter in individual events, you will have access to variable in your locals. These variables will depend on the event, and will be clearly listed when you add or edit it. 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 type is fired, all events from this event type are called. Variables are created for each event. Sometimes, however, the event type will execute and then ask for a variable in your event: in other words, some events 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 event type "msg_leave" that can be set on exits. This event can alter the message that will be sent to other characters when someone leave through this exit. + + @event/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 event, but will read from it when the event has been executed. + +### Events with parameters + +Some events are called without parameter. It has been the case for all examples we have seen before. In some cases, you can create events 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. The 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 event with the parameter "one": + + @event/add here = say one + +This event will only fire when the user says a sentence that contains "one". + +But what if we want to have an event that would fire if the user says 1 or one? We can provide several parameters, separated by a comma. + + @event/add here = say 1, one + +Or, still more keywords: + + @event/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 event). + +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. + +### 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): + +``` +@event 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. 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 + +Events can call other events, either now or a bit later. It is potentially very powerful. + +To use chained events, just use the `call_event` helper function. 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. Creating exits (opening its doors), waiting a bit, closing them, rolling around and stopping at a different station. That's quite a complex set of events, as it is, but let's only look at the part that opens and closes the doors: + + @event/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) +# Open the door +to_exit = get(id=23) +to_exit.name = "platform" +to_exit.aliases = ["p"] +to_exit.location = room +to_exit.destination = station +# Create the return exit +back_exit = get(id=24) +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 event will: + +1. Be called at 10:00 AM (specify 22:00 to say 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"? + + @event/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 events that call chained events: it isn't impossible for an event 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. + +## Using events in code + +This section describes events and event types from code, how to create new event types, 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 and have specific events fired. ### Adding new event types -Default events are great but you may need more events to fit with your purposes. For instance, if you have a `yell` command and would like a `can_yell` event in all your rooms. - -The way to do this is to add, below your class definition, lines to add these events. The `create_event_type` function should be called. It takes the following arguments: +Adding new event types should be done below your typeclasses. For instance, if you want to add a new event type on all your rooms, you should probably edit your `typeclasses/rooms.py` module. We'll see how to add a "push" event type to all objeects. To add a new event type, you should use the `create_event_type` function defined in `evennia.contrib.events.custom`. This function takes 4 arguments. - The class to have these events (defined above). -- The name of the event to add (str). +- The trigger of the event type to add (str). - The list of variables to be present when calling this events (list of str). - The help text of this event (str). -Here's an example of adding the `can_yell` event to all your rooms: +The variables define what will be accessible in the namespace of your event. Here, when we "push" an object, we would like to know what object is pushed, and who has pushed it (we'll limit this command to characters). You can edit `typeclasses/objects.py` to modify/add the following lines: ```python -# In typeclasses/rooms.py -from evennia import DefaultRoom -from evennia.contrib.events.custom import create_event_type +from evennia.contrib.events.custom import create_event_type, connect_event_types +from evennia.contrib.events.typeclasses import EventObject -class Room(DefaultRoom): - """ - Rooms are like any Object, except their location is None - (which is default). They also use basetype_setup() to - add locks so they cannot be puppeted or picked up. - (to change that, use at_object_creation instead) +class Object(EventObject): + # ... - See examples/object.py for a list of - properties and methods available on all Objects. - """ - pass -# Room events -create_event_type(Room, "can_yell", ["character", "room", "message"], """ - Can the character yell in this room? - This event is called when a character uses the 'yell' command - to yell in this room. This event is called BEFORE the character - yells, and the room can prevent the command by executing - 'deny()'. The 'character' variable contains the character - who wants to yell, the 'room' variable contains the room - in which the character wants to yell, and the 'message' - variable contains the message about to be yelled by the character. +# Object events +create_event_type(Object, "push", ["character", "obj"], """ + 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. """) + +# Force-update the new event types +connect_event_types() ``` -After this code has been executed, when you type `@event here` to see the events in this room, you will see the `can_yell` event. The first line of the help text is displayed as a short explanation, so you should always try to format your help files that way. +Here we have set: -At this point, the event has been added, but is not being called yet. To call it, you need to edit your `yell` command, and use the `call` function. You will probably end up with something like: +1. The typeclass (here, `Object`), meaning that this event will be accessible to all instances of `Object` or a child class. +2. `"push"` as the trigger (the name of the event type). +3. Two variables ("character" and "obj") that will be accessible in our event namespace. +4. A longer help text to describe more in details when this event will fire. It's best to keep this format as much as possible: a single line to briefly describe the event, a longer explanation on several lines, and the list of variables of this event. + +> It's best to call `connect_event_types()` after having defined new event types. It can be kept for the very last line of the file. The event system doesn't automatically integrate new event types, this function is to force it to do so. + +If you save this code and reload your game, you should see the new event type if you enter the `@event` command with an object as argument. + +### Calling an event in code + +The event system is accessible through a handler on all objects. This handler is named `events` 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 type on this object. + +To call an event, use the `events.call` method in an object. It takes as argument: + +- The name of the event type 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 event types](#adding-new-event-types). + +Following the same example, so far, we have created an event type on all objects, called "push". This event type 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 type. ```python -from evennia import Command -from evennia.contrib.events.helpers import call +from commands.command import Command -class CmdYell(Command): +class CmdPush(Command): """ - Yell in this room. + Push something. Usage: - yell + push + + Push something where you are, like an elevator button. """ - def func(self): - """Execute the command.""" - character = self.caller - location = character.location - message = self.args + key = "push" - # Check that the character can yell in this room - if not call(location, "can_yell", character, location, message): - # It has been denied, so stop the command here + def func(self): + """Called when pushing something.""" + if not self.args.strip(): + self.msg("Usage: push ") return - # Yell in this room - location.msg_contents("{char} yells: {msg}.", - mapping=dict(char=character, msg=message)) + # 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 type of this object + obj.events.call("push", self.caller, obj) ``` -Note that the `call` function takes as argument: +Here we use `events.call` with the following arguments: -- The object with the event (here, `location`). -- The name of the event to be called (here, `can_yell`). -- The variables as positional arguments, in the same order they were specified in `create_event`. +- `"push"`: the name of the event type 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`). -The `call` function will return `False` if the event has been interrupted by a `deny()` call. +In the "push" event 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 type 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. + @event/add rock = push + +In the event 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 helper functions -Helper functions, like `deny(), are defined in `contrib/events/helpers.py`. You can add your own helpers by creating a file named `helpers.py` in your `world` directory. The functions defined in this file will be added as helpers. Note that the docstring of each function will be used to generate automatic help. +Helper functions, like `deny(), are defined in `contrib/events/helpers.py`. You can add your own helpers by creating a file named `event_helpers.py` in your `world` directory. The functions defined in this file will be added as helpers. You can also decide to create your helper functions in another location, or even in several locations. To do so, edit the `EVENTS_HELPERS_LOCATIONS` 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: @@ -215,14 +633,6 @@ EVENTS_HELPERS_LOCATIONS = [ ] ``` -A helper function is really a Python function. Its docstring should be sufficiently elaborate, so the automatically-generated help of your helpers would prove as usable as the default helpers. - -### Adding new typeclasses - -Adding a new typeclass is not different from ing one, and will obey to the same rules: define the class as you have been accustomed to doing, and create the events with `create_event` under the class definition. - -Note: events obey the inheritance hierarchy: if you define events on the `Room` class, then create a typeclass inheriting from `Room`, the objects of this latter typeclass will have events of both typeclasses. - ## Disabling all events at once When events 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`): diff --git a/evennia/contrib/events/helpers.py b/evennia/contrib/events/helpers.py index 4f6ad17cc..a29c26bb7 100644 --- a/evennia/contrib/events/helpers.py +++ b/evennia/contrib/events/helpers.py @@ -51,7 +51,7 @@ def get(**kwargs): return object -def call(obj, event_name, seconds=0): +def call_event(obj, event_name, seconds=0): """ Call the specified event in X seconds. From 96b90dde1eed3a8b0695f5480c05965aeec1d3f4 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sat, 22 Apr 2017 13:15:16 -0700 Subject: [PATCH 085/133] Adopt the new names for the event system --- evennia/contrib/events/USERDOC.md | 332 ------ evennia/contrib/events/commands.py | 452 ++++---- evennia/contrib/events/custom.py | 265 ----- .../events/{helpers.py => eventfuncs.py} | 4 +- evennia/contrib/events/handler.py | 166 +-- evennia/contrib/events/scripts.py | 403 +++---- evennia/contrib/events/tests.py | 336 +++--- evennia/contrib/events/typeclasses.py | 980 +++++++++--------- evennia/contrib/events/utils.py | 235 +++++ 9 files changed, 1433 insertions(+), 1740 deletions(-) delete mode 100644 evennia/contrib/events/USERDOC.md delete mode 100644 evennia/contrib/events/custom.py rename evennia/contrib/events/{helpers.py => eventfuncs.py} (95%) create mode 100644 evennia/contrib/events/utils.py diff --git a/evennia/contrib/events/USERDOC.md b/evennia/contrib/events/USERDOC.md deleted file mode 100644 index c333cf5d9..000000000 --- a/evennia/contrib/events/USERDOC.md +++ /dev/null @@ -1,332 +0,0 @@ -# Evennia's event system, user documentation - -Evennia's event system allows to add dynamic features in your world without editing the source code. These features are placed on individual objects, and can offer opportunities to customize a few objects without customizing all of them. Usages can range from: - -- Adding dialogues to some characters (a NPC greeting player-characters). -- Adding some custom actions at specific in-game moments (a shop-keeper going home at 8 PM and coming back to the shop in the morning). -- Build complex quests (a set of actions with conditions required to obtain some reward or advantage). -- Deny a command from executing based on some conditions (prevent a character from going in some room without completing some quest). -- Have some objects react in specific ways when some action occurs (a character enters the room, a character says something). - -In short, the event system allows what other engines would implement through soft code or "scripting". The event system in Evennia doesn't rely on a homemade language, however, but on Python, and therefore allows almost everything possible through modifications to the source code. It's not necessary to know Evennia to use the event system, although knowing some basis of Evennia (the system of typeclasses and attributes, for instance) will not hurt. - -## Some basic examples - -Before beginning to use this system, it might be worth understanding its possibilities and basic features. The event system allows to create events that can be fired at specific moments. For instance, checking beforehand if a character has some characteristics before allowing him/her to walk through an exit. You will find some examples here (of course, this is only a list of examples, you could do so much more through this system): - - Edit the event 'can_traverse' of a specific exit: - if character.db.health < 30: - character.msg("You are obviously too weak to do that.") - deny() - else: # That's really opional here, but why not? - character.msg("Alrigh, you can go.") - -The `deny()` function denies characters from moving and so, after the message has been sent, the action is cancelled (he/she doesn't move). The `else:` statement and instructions are, as in standard Python, optional here. - - Edit the event 'eat' of a specific object: - if character.db.race != "goblin": - character.msg("This is a nice-tasting apple, as juicy as you'd like.") - else: - character.msg("You bite into the apple... and spit it out! Do people really eat that?!") - character.db.health -= 10 - -This time, we have an event that behaves differently when a character eats an apple... and is a goblin, or something else. Notice that the race system will need to be in your game, the event system just provides ways to access your regular Evennia objects and attributes. - - Edit the event 'time' of a specific NPC with the parameter '19:45': - character.execute_cmd("say Well, it's time to go home, folks!") - exit = character.location.search("up") - - exit.db.lock = False - exit.db.closed = False - move(character, "up") - exit.db.closed = True - exit.db.lock = True - -For this example, at 19:45 sharp (game time), the NPC leaves. It can be useful for a shop-keeper to just go in his/her room to sleep, and comeback in the morning. - -You will find more examples in this documentation, along with clear indications on how to use this feature in context. - -## Basic usage - -The event system relies, to a great extent, on its `@event` command. By default, immortals will be the only ones to have access to this command, for obvious security reasons. - -### The `@event` command - -The event system can be used on most Evennia objects, mostly typeclassed objects (rooms, exits, characters, objects, and the ones you want to add to your game, players don't use this system however). The first argument of the `@event` command is the name of the object you want to edit. - -#### Examining events - -Let's say we are in a room with two exist, north and south. You could see what events are currently linked with the `north` exit by entering: - - @event north - -The object to display or edit is searched in the room, by default, which makes editing rather easy. However, you can also provide its DBREF (a number) after a `#` sign, like this: - - @event #1 - -(In most settings, this will show the events linked with the character 1, the superuser.) - -This command will display a table, containing: - -- The name of each event in the first column. -- The number of events of this name, and the number of total lines of these events in the second column. -- A short help to tell you when the event is triggered in the third column. - -Notice that several events can be linked at the same location. For instance, you can have several events in an exit's "can_traverse" event: each event will be called in the order and each can prevent the character from going elsewhere. - -You can see the list of events of each name by using the same command, specifying the name of the event after an equal sign: - - @event south = can_traverse - -If you have more than one event of this name, they will be shown in a table with numbers starting from 1. You can examine a specific event by providing the number after the event's name: - - @event south = can_traverse 1 - -This command will allow you to examine the event more closely, including seeing its associated code. - -#### Creating a new event - -The `/add` switch should be used to add an event. It takes two arguments beyond the object's name/DBREF: - -1. After an = sign, the event to be edited (if not supplied, will display the list of possible events). -2. The parameters (optional). - -We'll see events with parameters later. For now, let's create an event 'can_traverse' connected to the exit 'north' in this room: - - @event/add north = can_traverse - -This will create a new event connected to this exit. It will be fired before a character traverses this exit. It is possible to prevent the character from moving at this point. - -This command should open a line-editor. This editor is described in greater details in another section. For now, you can write instructions as normal: - - 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 event. - -Then try to walk through this exit. Do it with another character if possible, too, to see the difference. - -#### Editing an event - -You can use the `/edit` switch to the `@event` command to edit an event. 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 events are connected at this location. - -You can type `@event/edit = ` to see the events that are linked at this location. If there is only one event, it will be opened in the editor; if more are defined, you will be asked for a number to provide (for instance, `@event/edit north = can_traverse 2`). - -#### Removing an event - -The command `@event` also provides a `/del` switch to remove an event. It takes the same arguments as the `/edit` switch: - -1. The name of the object. -2. The name of the event after an = sign. -3. Optionally a number if more than one event are located there. - -When removed, events are logged, so an administrator can retrieve its content, assuming the `/del` was an error and the administrator has access to log files. - -### The event editor - -When adding or editing an event, the event editor should open. It is basically the same as [EvEditor](https://github.com/evennia/evennia/wiki/EvEditor), which so ressemble VI, but it adds a couple of options to handle indentation. - -Python is a programming language that needs correct indentation. It is not an aesthetic concern, but a requirement to differentiate between blocks. The event editor will try to guess the right level of indentation to make your life easier, but it will not be perfect. - -- If you enter an instruction beginning by `if`, `elif`, or `else`, the editor will automatically increase the level of indentation of the next line. -- If the instruction is an `elif` or `else`, the editor will look for the opening block of `if` and match indentation. -- Blocks `while`, `for`, `try`, `except`, 'finally' obey the same rules. - -There are still some cases when you must tell the editor to reduce or increase indentation. The usual use cases are: - -1. When you close a condition or loop, the editor will not be able to tell. -2. When you want to keep the instruction on several lines, the editor will not bother with indentation. - -In both cases, you should use the `:>` command (increase indentation by one level) and `:<` (decrease indentation by one level). Indentation is always shown when you add a new line in your event. - -In all the cases shown above, you don't need to enter your indentation manually. Just change the indentation whenever needed, don't bother to write spaces or tabulations at the beginning of your line. For instance, you could enter the following lines in your client: - -``` -if character.id == 1: -character.msg("You're the big boss.") -else: -character.msg("I don't know who you are.") -:< -character.msg("This is not inside of the condition.") -``` - -This will produce the following code: - -``` -if character.id == 1: - character.msg("You're the big boss.") -else: - character.msg("I don't know who you are.") - -character.msg("This is not inside of the condition.") -``` - -You can also disable the automatic-indentation mode. Just enter the command `:=`. In this mode, you will have to manually type in the spaces or tabulations, the editor will not indent anything without you asking to do it. This mode can be useful if you copy/paste some code and want to keep the original indentation. - -## Using events - -The following sub-sections describe how to use events for various tasks, from the most simple to the most complex. - -### Standard Python code in events - -This might sound superfluous, considering the previous explanations, but remember you can use standard Python code in your events. Everything that you could do in the source code itself, like changing attributes or aliases, creating or removing objects, can be done through this system. What you will see in the following sub-sections doesn't rely on a new syntax of Python: they add functions and some features, at the best. Events aren't written in softcode, and their syntax might, at first glance, be a bit unfriendly to a user without any programming skills. However, you will probably grasp the basic concept very quickly, and will be able to move beyond simple events in good time. Don't overlook examples, in this documentation, or in your game. - -### The helper functions - -In order to make development a little easier, the event system provides helper functions to be used in events themselves. You don't have to use them, they are just shortcuts. - -The `deny()` function is such a helper. It allows to interrupt the event and the action that called it. In the `can_*` events, it can be used to prevent the action from happening. For instance, in `can_traverse` on exits, it can prevent the user from moving in that direction. One could have a `can_eat` event set on food that would prevent this character from eating this food. Or a `can_say` event in a room that would prevent the character from saying something here. - -Behind the scene, the `deny()` function raises an exception that is being intercepted by the handler of events. Calling this function in events that cannot be stopped may result in errors. - -You could easily add other helper functions. This will greatly depend on the objects you have defined in your game, and how often specific features have to be used by event users. - -### Variables in events - -Most events have variables. Variables are just Python variables. 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. The list of variables can change between events, and is always available in the help of the event. When you edit or add a new event, you'll see the help: read it carefully until you're familiar with this event, since it will give you useful information beyond the list of variables. - -Sometimes, variables in events can also be set to contain new directions. One simple example is the exits' "msg_leave" event, that is called when a character leaves a room through this exit. This event is executed and you can set a custom message when a character walks through this exit, which can sometimes be useful: - - @event/add down = msg_leave - message = "{character} falls into a hole in the ground!" - -Then, if the character Wilfred takes this story, others in the room will see: - - Wildred falls into a hole in the ground! - -### Events with parameters - -Some events are called without parameter. For instance, when a character traverses through an exit, the exit's "traverse" event is called with no argument. In some cases, you can create events 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. The 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 event with the parameter "one": - - @event/add here = say one - -This event will only fire when the user says "one" in this room. - -But what if we want to have an event that would fire if the user says 1 or one? We can provide several parameters, separated by a comma. - - @event/add here = say 1, one - -Or, still more keywords: - - @event/add here = say 1, one, ground - -This time, the user could say "ground" or "one" in the room, and it would fire the event. - -Not all events can take parameters, and these who do have a 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. - -### 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 argument, 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): - -``` -@event here = time 12:00 -# 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. 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 specific 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 - -Events can call other events, either now or a bit later. It is potentially very powerful. - -To use chained events, just use the `call` helper function. 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. Creating exits (opening its doors), waiting a bit, closing them, rolling around and stopping at a different station. That's quite a complex set of events, as it is, but let's only look at the part that opens and closes the doors: - -``` -@event here = time 10:00 -# 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) -# Open the door -to_exit = get(id=23) -to_exit.name = "platform" -to_exit.aliases = ["p"] -to_exit.location = room -to_exit.destination = station -# Create the return exit -back_exit = get(id=24) -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(room, "chain_1", 20) -``` - -This event will: - -1. Be called at 10:00 AM (specify 22:00 to say 10:00 PM). -2. Set an exit between the subway and the station. Notice that the exits already exist (you will 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"? - -``` -@event here = chain_1 -# 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 scene, the `call` function freezes all variables ("room", "station", "to_exit, "back_exit" in our example), so you don't need to define them afterward. - -A word of caution on events that call chained events: it isn't impossible for an event 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()`, 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. - -## Errors in events - -There are a lot of ways to make mistakes while writing events. Once you begin, you might encounter syntax errors very often, but leave them behind as you gain in confidence. However, there are still so many ways to trigger errors: passing the wrong arguments to a helper function is only one of many possible examples. - -When an event encounters an error, it stops abruptly and sends the error on a special channel, named "everror", on which you can connect or disconnect should the amount of information be overwhelming. These error messages will contain: - -- The name and ID of the object that encountered the error. -- The name and number of the event that crashed. -- The line number (and code) that caused the error. -- The short error messages (it might not be that short at times). - -The error will also be logged, so an administrator can still access it more completely, seeing the full traceback, which can help to understand the error sometimes. - diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index f171e1799..4f9295843 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -1,5 +1,5 @@ """ -Module containing the commands of the event system. +Module containing the commands of the callback system. """ from datetime import datetime @@ -10,77 +10,77 @@ 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.custom import get_event_handler +from evennia.contrib.events.utils import get_event_handler COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) # Permissions -WITH_VALIDATION = getattr(settings, "EVENTS_WITH_VALIDATION", None) -WITHOUT_VALIDATION = getattr(settings, "EVENTS_WITHOUT_VALIDATION", +WITH_VALIDATION = getattr(settings, "callbackS_WITH_VALIDATION", None) +WITHOUT_VALIDATION = getattr(settings, "callbackS_WITHOUT_VALIDATION", "immortals") -VALIDATING = getattr(settings, "EVENTS_VALIDATING", "immortals") +VALIDATING = getattr(settings, "callbackS_VALIDATING", "immortals") # Split help text -BASIC_HELP = "Add, edit or delete events." +BASIC_HELP = "Add, edit or delete callbacks." BASIC_USAGES = [ - "@event [= ]", - "@event/add = [parameters]", - "@event/edit = [event number]", - "@event/del = [event number]", - "@event/tasks [object name [= ]]", + "@call [= ]", + "@call/add = [parameters]", + "@call/edit = [callback number]", + "@call/del = [callback number]", + "@call/tasks [object name [= ]]", ] BASIC_SWITCHES = [ - "add - add and edit a new event", - "edit - edit an existing event", - "del - delete an existing event", + "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 = [ - "@event/accept [object name = [event number]]", + "@call/accept [object name = [callback number]]", ] VALIDATOR_SWITCHES = [ - "accept - show events to be validated or accept one", + "accept - show callbacks to be validated or accept one", ] BASIC_TEXT = """ -This command is used to manipulate events. An event can be linked to +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 event are active on an object: - @event self -You can also specify an event name if you want the list of events associated -with this object of this name: - @event north = can_traverse -You can also add a number after the event name to see details on one event: - @event here = say 2 -You can also add, edit or remove events using the add, edit or del switches. -Additionally, you can see the list of differed tasks created by events -(chained events to be called) using the /tasks switch. +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 callbacks to be called) using the /tasks switch. """ VALIDATOR_TEXT = """ -You can also use this command to validate events. Depending on your game -setting, some users might be allowed to add new events, but these events -will not be fired until you accept them. To see the events needing +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: - @event/accept -A table will show you the events that are not validated yet, who created -them and when. You can then accept a specific event: - @event here = enter 1 -Use the /del switch to remove events that should not be connected. + @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 CmdEvent(COMMAND_DEFAULT_CLASS): +class CmdCallback(COMMAND_DEFAULT_CLASS): """ - Command to edit events. + Command to edit callbacks. """ - key = "@event" - aliases = ["@events", "@ev"] + key = "@call" + aliases = ["@callback", "@callbacks", "@calls"] locks = "cmd:perm({})".format(VALIDATING) if WITH_VALIDATION: locks += " or perm({})".format(WITH_VALIDATION) @@ -101,7 +101,7 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): docstring (str): the help text to provide the caller for this command. """ - lock = "perm({}) or perm(events_validating)".format(VALIDATING) + lock = "perm({}) or perm(callbacks_validating)".format(VALIDATING) validator = caller.locks.check_lockstring(caller, lock) text = "\n" + BASIC_HELP + "\n\nUsages:\n " @@ -132,12 +132,12 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): WITHOUT_VALIDATION) autovalid = caller.locks.check_lockstring(caller, lock) - # First and foremost, get the event handler and set other variables + # First and foremost, get the callback handler and set other variables self.handler = get_event_handler() self.obj = None rhs = self.rhs or "" - self.event_name, sep, self.parameters = rhs.partition(" ") - self.event_name = self.event_name.lower() + 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: @@ -158,75 +158,73 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): return if switch == "": - self.list_events() + self.list_callbacks() elif switch == "add": - self.add_event() + self.add_callback() elif switch == "edit": - self.edit_event() + self.edit_callback() elif switch == "del": - self.del_event() + self.del_callback() elif switch == "accept" and validator: - self.accept_event() + 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_events(self): - """Display the list of events connected to the object.""" + def list_callbacks(self): + """Display the list of callbacks connected to the object.""" obj = self.obj - event_name = self.event_name + callback_name = self.callback_name parameters = self.parameters - events = self.handler.get_events(obj) - types = self.handler.get_event_types(obj) + callbacks = self.handler.get_callbacks(obj) + types = self.handler.get_events(obj) - if event_name: - # Check that the event name can be found in this object - created = events.get(event_name) + 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 event {} has been set on {}.".format(event_name, + self.msg("No callback {} has been set on {}.".format(callback_name, obj)) return if parameters: - # Check that the parameter points to an existing event + # Check that the parameter points to an existing callback try: number = int(parameters) - 1 assert number >= 0 - event = events[event_name][number] + callback = callbacks[callback_name][number] except (ValueError, AssertionError, IndexError): - self.msg("The event {} {} cannot be found in {}.".format( - event_name, parameters, obj)) + self.msg("The callback {} {} cannot be found in {}.".format( + callback_name, parameters, obj)) return - # Display the events' details - author = event.get("author") + # Display the callback's details + author = callback.get("author") author = author.key if author else "|gUnknown|n" - updated_by = event.get("updated_by") + updated_by = callback.get("updated_by") updated_by = updated_by.key if updated_by else "|gUnknown|n" - created_on = event.get("created_on") - created_on = created_on.strftime("%Y-%m-%d %H:%M:%S") \ - if created_on else "|gUnknown|n" - updated_on = event.get("updated_on") - updated_on = updated_on.strftime("%Y-%m-%d %H:%M:%S") \ - if updated_on else "|gUnknown|n" - msg = "Event {} {} of {}:".format(event_name, parameters, obj) + 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 event.get("valid"): - msg += "\nThis event is |rconnected|n and active." + if callback.get("valid"): + msg += "\nThis callback is |rconnected|n and active." else: - msg += "\nThis event |rhasn't been|n accepted yet." + msg += "\nThis callback |rhasn't been|n accepted yet." - msg += "\nEvent code:\n" - msg += raw(event["code"]) + msg += "\nCallback code:\n" + msg += raw(callback["code"]) self.msg(msg) return - # No parameter has been specified, display the table of events + # No parameter has been specified, display the table of callbacks cols = ["Number", "Author", "Updated", "Param"] if self.is_validator: cols.append("Valid") @@ -234,12 +232,12 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): table = EvTable(*cols, width=78) table.reformat_column(0, align="r") now = datetime.now() - for i, event in enumerate(created): - author = event.get("author") + for i, callback in enumerate(created): + author = callback.get("author") author = author.key if author else "|gUnknown|n" - updated_on = event.get("updated_on") + updated_on = callback.get("updated_on") if updated_on is None: - updated_on = event.get("created_on") + updated_on = callback.get("created_on") if updated_on: updated_on = "{} ago".format(time_format( @@ -247,211 +245,211 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): 4).capitalize()) else: updated_on = "|gUnknown|n" - parameters = event.get("parameters", "") + parameters = callback.get("parameters", "") row = [str(i + 1), author, updated_on, parameters] if self.is_validator: - row.append("Yes" if event.get("valid") else "No") + 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(events.keys()))) - table = EvTable("Event name", "Number", "Description", + 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(events.get(name, [])) - lines = sum(len(e["code"].splitlines()) for e in \ - events.get(name, [])) + 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.splitlines()[0] + description = types.get(name, (None, "Chained callback."))[1] + description = description.strip("\n").splitlines()[0] table.add_row(name, no, description) self.msg(unicode(table)) - def add_event(self): - """Add an event.""" + def add_callback(self): + """Add a callback.""" obj = self.obj - event_name = self.event_name - types = self.handler.get_event_types(obj) + callback_name = self.callback_name + types = self.handler.get_events(obj) - # Check that the event exists - if not event_name.startswith("chain_") and not event_name in types: - self.msg("The event name {} can't be found in {} of " \ - "typeclass {}.".format(event_name, obj, type(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(event_name, (None, "Chain event")) + definition = types.get(callback_name, (None, "Chained callback")) description = definition[1] - self.msg(raw(description)) + self.msg(raw(description.strip("\n"))) # Open the editor - event = self.handler.add_event(obj, event_name, "", + callback = self.handler.add_callback(obj, callback_name, "", self.caller, False, parameters=self.parameters) - # Lock this event right away - self.handler.db.locked.append((obj, event_name, event["number"])) + # Lock this callback right away + self.handler.db.locked.append((obj, callback_name, callback["number"])) - # Open the editor for this event - self.caller.db._event = event + # Open the editor for this callback + self.caller.db._callback = callback EvEditor(self.caller, loadfunc=_ev_load, savefunc=_ev_save, - quitfunc=_ev_quit, key="Event {} of {}".format( - event_name, obj), persistent=True, codefunc=_ev_save) + quitfunc=_ev_quit, key="Callback {} of {}".format( + callback_name, obj), persistent=True, codefunc=_ev_save) - def edit_event(self): - """Edit an event.""" + def edit_callback(self): + """Edit a callback.""" obj = self.obj - event_name = self.event_name + callback_name = self.callback_name parameters = self.parameters - events = self.handler.get_events(obj) - types = self.handler.get_event_types(obj) + callbacks = self.handler.get_callbacks(obj) + types = self.handler.get_events(obj) - # If no event name is specified, display the list of events - if not event_name: - self.list_events() + # If no callback name is specified, display the list of callbacks + if not callback_name: + self.list_callbacks() return - # Check that the event exists - if not event_name in events: - self.msg("The event name {} can't be found in {}.".format( - event_name, obj)) + # 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 event, just edit it - if len(events[event_name]) == 1: + # If there's only one callback, just edit it + if len(callbacks[callback_name]) == 1: number = 0 - event = events[event_name][0] + callback = callbacks[callback_name][0] else: if not parameters: - self.msg("Which event do you wish to edit? Specify a number.") - self.list_events() + self.msg("Which callback do you wish to edit? Specify a number.") + self.list_callbacks() return - # Check that the parameter points to an existing event + # Check that the parameter points to an existing callback try: number = int(parameters) - 1 assert number >= 0 - event = events[event_name][number] + callback = callbacks[callback_name][number] except (ValueError, AssertionError, IndexError): - self.msg("The event {} {} cannot be found in {}.".format( - event_name, parameters, obj)) + 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 event["author"] is not self.caller: - self.msg("You cannot edit this event created by someone else.") + 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 event is locked (edited by someone else) - if (obj, event_name, number) in self.handler.db.locked: - self.msg("This event is locked, you cannot edit it.") + # 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, event_name, number)) - # Check the definition of the event - definition = types.get(event_name, (None, "Chained event")) + self.handler.db.locked.append((obj, callback_name, number)) + + # Check the definition of the callback + definition = types.get(callback_name, (None, "Chained callback")) description = definition[1] - self.msg(raw(description)) + self.msg(raw(description.strip("\n"))) # Open the editor - event = dict(event) - event["obj"] = obj - event["name"] = event_name - event["number"] = number - self.caller.db._event = event + 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="Event {} of {}".format( - event_name, obj), persistent=True, codefunc=_ev_save) + quitfunc=_ev_quit, key="Callback {} of {}".format( + callback_name, obj), persistent=True, codefunc=_ev_save) - def del_event(self): - """Delete an event.""" + def del_callback(self): + """Delete a callback.""" obj = self.obj - event_name = self.event_name + callback_name = self.callback_name parameters = self.parameters - events = self.handler.get_events(obj) - types = self.handler.get_event_types(obj) + callbacks = self.handler.get_callbacks(obj) + types = self.handler.get_events(obj) - # If no event name is specified, display the list of events - if not event_name: - self.list_events() + # If no callback name is specified, display the list of callbacks + if not callback_name: + self.list_callbacks() return - # Check that the event exists - if not event_name in events: - self.msg("The event name {} can't be found in {}.".format( - event_name, obj)) + # 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 event, just delete it - if len(events[event_name]) == 1: + # If there's only one callback, just delete it + if len(callbacks[callback_name]) == 1: number = 0 - event = events[event_name][0] + callback = callbacks[callback_name][0] else: if not parameters: - self.msg("Which event do you wish to delete? Specify " \ + self.msg("Which callback do you wish to delete? Specify " \ "a number.") - self.list_events() + self.list_callbacks() return - # Check that the parameter points to an existing event + # Check that the parameter points to an existing callback try: number = int(parameters) - 1 assert number >= 0 - event = events[event_name][number] + callback = callbacks[callback_name][number] except (ValueError, AssertionError, IndexError): - self.msg("The event {} {} cannot be found in {}.".format( - event_name, parameters, obj)) + 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 event["author"] is not self.caller: - self.msg("You cannot delete this event created by someone else.") + 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 event is locked (edited by someone else) - if (obj, event_name, number) in self.handler.db.locked: - self.msg("This event is locked, you cannot delete it.") + # 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 event - self.handler.del_event(obj, event_name, number) - self.msg("The event {}[{}] of {} was deleted.".format( - event_name, number + 1, obj)) + # 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_event(self): - """Accept an event.""" + def accept_callback(self): + """Accept a callback.""" obj = self.obj - event_name = self.event_name + callback_name = self.callback_name parameters = self.parameters - # If no object, display the list of events to be checked + # 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: - events = self.handler.db.events.get(obj, {}).get(name) - if events is None: + callbacks = self.handler.get_callbacks(obj).get(name) + if callbacks is None: continue try: - event = events[number] + callback = callbacks[number] except IndexError: continue type_name = obj.typeclass_path.split(".")[-1] - by = event.get("updated_by") + by = callback.get("updated_by") by = by.key if by else "|gUnknown|n" - updated_on = event.get("updated_on") + updated_on = callback.get("updated_on") if updated_on is None: - updated_on = event.get("created_on") + updated_on = callback.get("created_on") if updated_on: updated_on = "{} ago".format(time_format( @@ -465,100 +463,100 @@ class CmdEvent(COMMAND_DEFAULT_CLASS): return # An object was specified - events = self.handler.get_events(obj) - types = self.handler.get_event_types(obj) + callbacks = self.handler.get_callbacks(obj) + types = self.handler.get_events(obj) - # If no event name is specified, display the list of events - if not event_name: - self.list_events() + # If no callback name is specified, display the list of callbacks + if not callback_name: + self.list_callbacks() return - # Check that the event exists - if not event_name in events: - self.msg("The event name {} can't be found in {}.".format( - event_name, obj)) + # 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 event do you wish to accept? Specify a number.") - self.list_events() + self.msg("Which callback do you wish to accept? Specify a number.") + self.list_callbacks() return - # Check that the parameter points to an existing event + # Check that the parameter points to an existing callback try: number = int(parameters) - 1 assert number >= 0 - event = events[event_name][number] + callback = callbacks[callback_name][number] except (ValueError, AssertionError, IndexError): - self.msg("The event {} {} cannot be found in {}.".format( - event_name, parameters, obj)) + self.msg("The callback {} {} cannot be found in {}.".format( + callback_name, parameters, obj)) return - # Accept the event - if event["valid"]: - self.msg("This event has already been accepted.") + # Accept the callback + if callback["valid"]: + self.msg("This callback has already been accepted.") else: - self.handler.accept_event(obj, event_name, number) - self.msg("The event {} {} of {} has been accepted.".format( - event_name, parameters, obj)) + 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 - event_name = self.event_name + 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 event_name: - tasks = [task for task in tasks if task[3] == event_name] + if callback_name: + tasks = [task for task in tasks if task[3] == callback_name] tasks.sort() - table = EvTable("ID", "Object", "Event", "In", width=78) + table = EvTable("ID", "Object", "Callback", "In", width=78) table.reformat_column(0, align="r") now = datetime.now() - for task_id, future, obj, event_name in tasks: + 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, event_name, delta) + 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._event and caller.db._event.get("code", "") or "" + return caller.db._callback and caller.db._callback.get("code", "") or "" def _ev_save(caller, buf): - """Save and add the event.""" + """Save and add the callback.""" lock = "perm({}) or perm(events_without_validation)".format( WITHOUT_VALIDATION) autovalid = caller.locks.check_lockstring(caller, lock) - event = caller.db._event + callback = caller.db._callback handler = get_event_handler() - if not handler or not event or not all(key in event for key in \ + 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 event.") + caller.msg("Couldn't save this callback.") return False - if (event["obj"], event["name"], event["number"]) in handler.db.locked: - handler.db.locked.remove((event["obj"], event["name"], - event["number"])) + if (callback["obj"], callback["name"], callback["number"]) in handler.db.locked: + handler.db.locked.remove((callback["obj"], callback["name"], + callback["number"])) - handler.edit_event(event["obj"], event["name"], event["number"], buf, + handler.edit_callback(callback["obj"], callback["name"], callback["number"], buf, caller, valid=autovalid) return True def _ev_quit(caller): - event = caller.db._event + callback = caller.db._callback handler = get_event_handler() - if not handler or not event or not all(key in event for key in \ + 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 event.") + caller.msg("Couldn't save this callback.") return False - if (event["obj"], event["name"], event["number"]) in handler.db.locked: - handler.db.locked.remove((event["obj"], event["name"], - event["number"])) + if (callback["obj"], callback["name"], callback["number"]) in handler.db.locked: + handler.db.locked.remove((callback["obj"], callback["name"], + callback["number"])) - del caller.db._event + del caller.db._callback caller.msg("Exited the code editor.") diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py deleted file mode 100644 index a4cef9a6e..000000000 --- a/evennia/contrib/events/custom.py +++ /dev/null @@ -1,265 +0,0 @@ -""" -Functions to extend the event system. - -These funcitons are not helpers (helpers are in a separate module) -and are designed to be used more by developers to add event types. - -""" - -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.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 - -hooks = [] -event_types = [] - -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 create_event_type(typeclass, event_name, variables, help_text, - custom_add=None, custom_call=None): - """ - Create a new event type for a specific typeclass. - - Args: - typeclass (type): the class defining tye typeclass to be used. - event_name (str): the name of the event to be added. - variables (list of str): a list of variable names. - help_text (str): a help text of the event. - custom_add (function, optional): a callback to call when adding - the new event. - custom_call (function, optional): a callback to call when - preparing to call the event. - - Events obey the inheritance hierarchy: if you set an event on - DefaultRoom, for instance, and if your Room typeclass inherits - from DefaultRoom (the default), the event will be available to - all rooms. Objects of the typeclass set in argument will be - able to set one or more events of that name. - - If the event type already exists in the typeclass, replace it. - - """ - typeclass_name = typeclass.__module__ + "." + typeclass.__name__ - event_types.append((typeclass_name, event_name, variables, help_text, - custom_add, custom_call)) - -def invalidate_event_type(typeclass, event_name): - """ - Invalidate a descending event type defined above in the hierarchy. - - Event types follow the hierarchy of inheritance. Events defined - in DefaultObjects would be accessible in DefaultRooms, for instance. - This can ensure that the event is limited and doesn't apply to - children with instances. - - Args: - typeclass (type): the class describing the typeclass. - event_name (str): the name of the event to invalidate. - - Example: - create_event_type(DefaultObject, "get", ["object"], "Someone gets.") - invalidate_event_type(DefaultRoom, "get") - # room objects won't have the 'get' event - - """ - typeclass_name = typeclass.__module__ + "." + typeclass.__name__ - event_types.append((typeclass_name, event_name, None, "", None, None)) - -def connect_event_types(): - """ - Connect the event types when the script runs. - - This method should be called automatically by the event handler - (the script). It might be useful, however, to call it after adding - new event types in typeclasses. - - """ - try: - script = ScriptDB.objects.get(db_key="event_handler") - except ScriptDB.DoesNotExist: - logger.log_trace("Can't connect event types, the event handler " \ - "cannot be found.") - return - - if script.ndb.event_types is None: - return - - t_event_types = list(event_types) - while t_event_types: - typeclass_name, event_name, variables, help_text, \ - custom_add, custom_call = t_event_types[0] - - # Get the event types for this typeclass - if typeclass_name not in script.ndb.event_types: - script.ndb.event_types[typeclass_name] = {} - types = script.ndb.event_types[typeclass_name] - - # Add or replace the event - help_text = dedent(help_text.strip("\n")) - types[event_name] = (variables, help_text, custom_add, custom_call) - del t_event_types[0] - -# 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. - - 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. - - 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. - - """ - 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 create_time_event(obj, event_name, number, parameters): - """ - Create a time-related event. - - Args: - obj (Object): the object on which stands 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(events, parameters): - """ - Custom call for events with keywords (like push, or pull, or turn...). - - This function should be imported and added as a custom_call - parameter to add the event type 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' event can - be set to trigger when the player 'push 1' or 'push one'. - - Args: - events (list of dict): the list of events to be called. - parameters (str): the actual parameters entered to trigger the event. - - Returns: - A list containing the event dictionaries to be called. - - """ - key = parameters.strip().lower() - to_call = [] - for event in events: - keys = event["parameters"] - if not keys or key in [p.strip().lower() for p in keys.split(",")]: - to_call.append(event) - - return to_call - -def phrase_event(events, parameters): - """ - Custom call for events with keywords in sentences (like say or whisper). - - This function should be imported and added as a custom_call - parameter to add the event type when the event supports keywords - in phrase as parameters. Keywords in parameters are one or more - words separated by a comma. For instance, a 'say yes, okay' event - can be set to trigger when the player says something containing - either "yes" or "okay" (maybe 'say I don't like it, but okay'). - - Args: - events (list of dict): the list of events to be called. - parameters (str): the actual parameters entered to trigger the event. - - Returns: - A list containing the event dictionaries to be called. - - """ - 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 event in events: - keys = event["parameters"] - if not keys or any(key.strip().lower() in words for key in keys.split(",")): - to_call.append(event) - - return to_call diff --git a/evennia/contrib/events/helpers.py b/evennia/contrib/events/eventfuncs.py similarity index 95% rename from evennia/contrib/events/helpers.py rename to evennia/contrib/events/eventfuncs.py index a29c26bb7..b72afff86 100644 --- a/evennia/contrib/events/helpers.py +++ b/evennia/contrib/events/eventfuncs.py @@ -1,7 +1,7 @@ """ -Module defining basic helpers for the event system. +Module defining basic eventfuncs for the event system. -Hlpers are just Python functions that can be used inside of events. They +Eventfuncs are just Python functions that can be used inside of calllbacks. """ diff --git a/evennia/contrib/events/handler.py b/evennia/contrib/events/handler.py index f6bd7ae98..fdec51e0e 100644 --- a/evennia/contrib/events/handler.py +++ b/evennia/contrib/events/handler.py @@ -4,15 +4,15 @@ Module containing the EventHandler for individual objects. from collections import namedtuple -class EventsHandler(object): +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.events`) is a - shortcut to manipulating events within this object, getting, + developers. This handler (accessible through `obj.callbacks`) is a + shortcut to manipulating callbacks within this object, getting, adding, editing, deleting and calling them. """ @@ -24,41 +24,45 @@ class EventsHandler(object): def all(self): """ - Return all events linked to this object. + Return all callbacks linked to this object. Returns: - All events in a dictionary event_name: event}. The event - is returned as a namedtuple to simply manipulation. + All callbacks in a dictionary callback_name: callback}. The callback + is returned as a namedtuple to simplify manipulation. """ - events = {} + callbacks = {} handler = type(self).script if handler: - dicts = handler.get_events(self.obj) - for event_name, in_list in dicts.items(): + dicts = handler.get_callbacks(self.obj) + for callback_name, in_list in dicts.items(): new_list = [] - for event in in_list: - event = self.format_event(event) - new_list.append(event) + for callback in in_list: + callback = self.format_callback(callback) + new_list.append(callback) if new_list: - events[event_name] = new_list + callbacks[callback_name] = new_list - return events + return callbacks - def get(self, event_name): + def get(self, callback_name): """ - Return the events associated with this name. + Return the callbacks associated with this name. Args: - event_name (str): the name of the event. + callback_name (str): the name of the callback. - This method returns a list of Event objects (namedtuple - representations). If the event name cannot be found in the - object's events, return an empty list. + 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(event_name, []) + return self.all().get(callback_name, []) def get_variable(self, variable_name): """ @@ -77,124 +81,124 @@ class EventsHandler(object): return None - def add(self, event_name, code, author=None, valid=False, parameters=""): + def add(self, callback_name, code, author=None, valid=False, parameters=""): """ - Add a new event for this object. + Add a new callback for this object. Args: - event_name (str): the name of the event to add. - code (str): the Python code associated with this event. - author (Character or Player, optional): the author of the event. - valid (bool, optional): should the event be connected? + 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 event definition that was added or None. + The callback definition that was added or None. """ handler = type(self).script if handler: - return self.format_event(handler.add_event(self.obj, event_name, code, + return self.format_callback(handler.add_callback(self.obj, callback_name, code, author=author, valid=valid, parameters=parameters)) - def edit(self, event_name, number, code, author=None, valid=False): + def edit(self, callback_name, number, code, author=None, valid=False): """ - Edit an existing event bound to this object. + Edit an existing callback bound to this object. Args: - event_name (str): the name of the event to edit. - number (int): the event number to be changed. - code (str): the Python code associated with this event. - author (Character or Player, optional): the author of the event. - valid (bool, optional): should the event be connected? + 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 event definition that was edited or None. + The callback definition that was edited or None. Raises: - RuntimeError if the event is locked. + RuntimeError if the callback is locked. """ handler = type(self).script if handler: - return self.format_event(handler.edit_event(self.obj, event_name, + return self.format_callback(handler.edit_callback(self.obj, callback_name, number, code, author=author, valid=valid)) - def remove(self, event_name, number): + def remove(self, callback_name, number): """ - Delete the specified event bound to this object. + Delete the specified callback bound to this object. Args: - event_name (str): the name of the event to delete. - number (int): the number of the event to delete. + callback_name (str): the name of the callback to delete. + number (int): the number of the callback to delete. Raises: - RuntimeError if the event is locked. + RuntimeError if the callback is locked. """ handler = type(self).script if handler: - handler.del_event(self.obj, event_name, number) + handler.del_callback(self.obj, callback_name, number) - def call(self, event_name, *args, **kwargs): + def call(self, callback_name, *args, **kwargs): """ - Call the specified event(s) bound to this object. + Call the specified callback(s) bound to this object. Args: - event_name (str): the event name to call. - *args: additional variables for this event. + callback_name (str): the callback name to call. + *args: additional variables for this callback. Kwargs: - number (int, optional): call just a specific event. - parameters (str, optional): call an event with parameters. + 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 event was called without interruption, - False otherwise. If the EventHandler isn't found, return + 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_event(self.obj, event_name, *args, **kwargs) + return handler.call(self.obj, callback_name, *args, **kwargs) return None @staticmethod - def format_event(event): + def format_callback(callback): """ - Return the Event namedtuple to represent the specified event. + Return the callback namedtuple to represent the specified callback. Args: - event (dict): the event definition. + callback (dict): the callback definition. - The event given in argument should be a dictionary containing - the expected fields for an event (code, author, valid...). + The callback given in argument should be a dictionary containing + the expected fields for a callback (code, author, valid...). """ - if "obj" not in event: - event["obj"] = None - if "name" not in event: - event["name"] = "unknown" - if "number" not in event: - event["number"] = -1 - if "code" not in event: - event["code"] = "" - if "author" not in event: - event["author"] = None - if "valid" not in event: - event["valid"] = False - if "parameters" not in event: - event["parameters"] = "" - if "created_on" not in event: - event["created_on"] = None - if "updated_by" not in event: - event["updated_by"] = None - if "updated_on" not in event: - event["updated_on"] = None + 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 Event(**event) + return Callback(**callback) -Event = namedtuple("Event", ("obj", "name", "number", "code", "author", +Callback = namedtuple("Callback", ("obj", "name", "number", "code", "author", "valid", "parameters", "created_on", "updated_by", "updated_on")) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index d46909c33..e63486db6 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -14,10 +14,9 @@ from evennia import logger from evennia.utils.create import create_channel from evennia.utils.dbserialize import dbserialize from evennia.utils.utils import all_from_module, delay -from evennia.contrib.events.custom import connect_event_types, get_next_wait from evennia.contrib.events.exceptions import InterruptEvent -from evennia.contrib.events.handler import EventsHandler as Handler -from evennia.contrib.events import typeclasses +from evennia.contrib.events.handler import CallbackHandler +from evennia.contrib.events.utils import get_next_wait, EVENTS # Constants RE_LINE_ERROR = re.compile(r'^ File "\", line (\d+)') @@ -28,7 +27,7 @@ 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 types (in a non-persistent attribute) and events (in a + event (in a non-persistent attribute) and callbacks (in a persistent attribute). The script method would help adding, editing and deleting these events. @@ -41,7 +40,7 @@ class EventHandler(DefaultScript): self.persistent = True # Permanent data to be stored - self.db.events = {} + self.db.callbacks = {} self.db.to_valid = [] self.db.locked = [] @@ -56,21 +55,22 @@ class EventHandler(DefaultScript): (including when it's reloaded). This hook performs the following tasks: - - Refresh and re-connect event types. + - Create temporarily stored events. - Generate locals (individual events' namespace). - - Load event helpers, including user-defined ones. + - 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.event_types = {} - connect_event_types() + 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.helpers"] - addresses.extend(getattr(settings, "EVENTS_HELPERS_LOCATIONS", [])) + addresses = ["evennia.contrib.events.eventfuncs"] + addresses.extend(getattr(settings, "EVENTFUNCS_LOCATIONS", [])) for address in addresses: self.ndb.fresh_locals.update(all_from_module(address)) @@ -84,9 +84,10 @@ class EventHandler(DefaultScript): delay(seconds, complete_task, task_id) - # Place the script in the EventsHandler - Handler.script = self - DefaultObject.events = typeclasses.EventObject.events + # 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: @@ -97,73 +98,42 @@ class EventHandler(DefaultScript): def get_events(self, obj): """ - Return a dictionary of the object's events. - - Args: - obj (Object): the connected objects. - - Returns: - A dictionary of the object's events. - - Note: - This method can be useful to override in some contexts, - when several objects would share events. - - """ - obj_events = self.db.events.get(obj, {}) - events = {} - for event_name, event_list in obj_events.items(): - new_list = [] - for i, event in enumerate(event_list): - event = dict(event) - event["obj"] = obj - event["name"] = event_name - event["number"] = i - new_list.append(event) - - if new_list: - events[event_name] = new_list - - return events - - def get_event_types(self, obj): - """ - Return a dictionary of event types on this object. + Return a dictionary of events on this object. Args: obj (Object): the connected object. Returns: - A dictionary of the object's event types. + A dictionary of the object's events. Note: - Event types would define what the object can have as - events. Note, however, that chained events will not - appear in event types and are handled separately. + Events would define what the object can have as + callbacks. Note, however, that chained callbacks will not + appear in events and are handled separately. """ - types = {} - event_types = self.ndb.event_types + 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 event_types.get(typeclass_name, {}).items(): + 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 types: - types[key] = etype + if key not in events: + events[key] = etype # Look for the parent classes for parent in typeclass.__bases__: classes.put(parent) - return types + return events def get_variable(self, variable_name): """ @@ -190,34 +160,66 @@ class EventHandler(DefaultScript): """ return self.ndb.current_locals.get(variable_name) - def add_event(self, obj, event_name, code, author=None, valid=False, + 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 event. + Add the specified callback. Args: obj (Object): the Evennia typeclassed object to be extended. - event_name (str): the name of the event to add. - code (str): the Python code associated with this event. - author (Character or Player, optional): the author of the event. - valid (bool, optional): should the event be connected? + 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. - This method doesn't check that the event type exists. + Note: + This method doesn't check that the callback type exists. """ - obj_events = self.db.events.get(obj, {}) - if not obj_events: - self.db.events[obj] = {} - obj_events = self.db.events[obj] + obj_callbacks = self.db.callbacks.get(obj, {}) + if not obj_callbacks: + self.db.callbacks[obj] = {} + obj_callbacks = self.db.callbacks[obj] - events = obj_events.get(event_name, []) - if not events: - obj_events[event_name] = [] - events = obj_events[event_name] + callbacks = obj_callbacks.get(callback_name, []) + if not callbacks: + obj_callbacks[callback_name] = [] + callbacks = obj_callbacks[callback_name] - # Add the event in the list - events.append({ + # Add the callback in the list + callbacks.append({ "created_on": datetime.now(), "author": author, "valid": valid, @@ -227,56 +229,57 @@ class EventHandler(DefaultScript): # If not valid, set it in 'to_valid' if not valid: - self.db.to_valid.append((obj, event_name, len(events) - 1)) + self.db.to_valid.append((obj, callback_name, len(callbacks) - 1)) # Call the custom_add if needed - custom_add = self.get_event_types(obj).get( - event_name, [None, None, None])[2] + custom_add = self.get_events(obj).get( + callback_name, [None, None, None, None])[3] if custom_add: - custom_add(obj, event_name, len(events) - 1, parameters) + custom_add(obj, callback_name, len(callbacks) - 1, parameters) # Build the definition to return (a dictionary) - definition = dict(events[-1]) + definition = dict(callbacks[-1]) definition["obj"] = obj - definition["name"] = event_name - definition["number"] = len(events) - 1 + definition["name"] = callback_name + definition["number"] = len(callbacks) - 1 return definition - def edit_event(self, obj, event_name, number, code, author=None, + def edit_callback(self, obj, callback_name, number, code, author=None, valid=False): """ - Edit the specified event. + Edit the specified callback. Args: obj (Object): the Evennia typeclassed object to be edited. - event_name (str): the name of the event to edit. - number (int): the event number to be changed. - code (str): the Python code associated with this event. - author (Character or Player, optional): the author of the event. - valid (bool, optional): should the event be connected? + 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 event is locked. + RuntimeError if the callback is locked. - This method doesn't check that the event type exists. + Note: + This method doesn't check that the callback type exists. """ - obj_events = self.db.events.get(obj, {}) - if not obj_events: - self.db.events[obj] = {} - obj_events = self.db.events[obj] + obj_callbacks = self.db.callbacks.get(obj, {}) + if not obj_callbacks: + self.db.callbacks[obj] = {} + obj_callbacks = self.db.callbacks[obj] - events = obj_events.get(event_name, []) - if not events: - obj_events[event_name] = [] - events = obj_events[event_name] + 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, event_name, number) in self.db.locked: - raise RuntimeError("this event is locked.") + if (obj, callback_name, number) in self.db.locked: + raise RuntimeError("this callback is locked.") - # Edit the event - events[number].update({ + # Edit the callback + callbacks[number].update({ "updated_on": datetime.now(), "updated_by": author, "valid": valid, @@ -284,118 +287,118 @@ class EventHandler(DefaultScript): }) # If not valid, set it in 'to_valid' - if not valid and (obj, event_name, number) not in self.db.to_valid: - self.db.to_valid.append((obj, event_name, number)) - elif valid and (obj, event_name, number) in self.db.to_valid: - self.db.to_valid.remove((obj, event_name, number)) + 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(events[number]) + definition = dict(callbacks[number]) definition["obj"] = obj - definition["name"] = event_name + definition["name"] = callback_name definition["number"] = number return definition - def del_event(self, obj, event_name, number): + def del_callback(self, obj, callback_name, number): """ - Delete the specified event. + Delete the specified callback. Args: - obj (Object): the typeclassed object containing the event. - event_name (str): the name of the event to delete. - number (int): the number of the event to delete. + 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 event is locked. + RuntimeError if the callback is locked. """ - obj_events = self.db.events.get(obj, {}) - events = obj_events.get(event_name, []) + obj_callbacks = self.db.callbacks.get(obj, {}) + callbacks = obj_callbacks.get(callback_name, []) # If locked, don't edit it - if (obj, event_name, number) in self.db.locked: - raise RuntimeError("this event is locked.") + if (obj, callback_name, number) in self.db.locked: + raise RuntimeError("this callback is locked.") - # Delete the event itself + # Delete the callback itself try: - code = events[number]["code"] + code = callbacks[number]["code"] except IndexError: return else: - logger.log_info("Deleting event {} {} of {}:\n{}".format( - event_name, number, obj, code)) - del events[number] + logger.log_info("Deleting callback {} {} of {}:\n{}".format( + callback_name, number, obj, code)) + del callbacks[number] - # Change IDs of events to be validated + # Change IDs of callbacks to be validated i = 0 while i < len(self.db.to_valid): - t_obj, t_event_name, t_number = self.db.to_valid[i] - if obj is t_obj and event_name == t_event_name: + 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 event + # Strictly equal, delete the callback del self.db.to_valid[i] i -= 1 elif t_number > number: - # Change the ID for this event - self.db.to_valid.insert(i, (t_obj, t_event_name, + # 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 event + # Update locked callback for i, line in enumerate(self.db.locked): - t_obj, t_event_name, t_number = line - if obj is t_obj and event_name == t_event_name: + 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_event_name, t_number - 1) + self.db.locked[i] = (t_obj, t_callback_name, t_number - 1) - # Delete time-related events associated with this object + # Delete time-related callbacks associated with this object for script in list(obj.scripts.all()): - if isinstance(script, TimeEventScript): - if script.obj is obj and script.db.event_name == event_name: + 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_event(self, obj, event_name, number): + def accept_callback(self, obj, callback_name, number): """ - Valid an event. + Valid a callback. Args: - obj (Object): the object containing the event. - event_name (str): the name of the event. - number (int): the number of the event. + obj (Object): the object containing the callback. + callback_name (str): the name of the callback. + number (int): the number of the callback. """ - obj_events = self.db.events.get(obj, {}) - events = obj_events.get(event_name, []) + obj_callbacks = self.db.callbacks.get(obj, {}) + callbacks = obj_callbacks.get(callback_name, []) - # Accept and connect the event - events[number].update({"valid": True}) - if (obj, event_name, number) in self.db.to_valid: - self.db.to_valid.remove((obj, event_name, number)) + # 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_event(self, obj, event_name, *args, **kwargs): + def call(self, obj, callback_name, *args, **kwargs): """ - Call the event. + Call the connected callbacks. Args: obj (Object): the Evennia typeclassed object. - event_name (str): the event name to call. - *args: additional variables for this event. + callback_name (str): the callback name to call. + *args: additional variables for this callback. Kwargs: - number (int, optional): call just a specific event. - parameters (str, optional): call an event with parameters. + 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 event was called without interruption, + True to report the callback was called without interruption, False otherwise. """ - # First, look for the event type corresponding to this name + # First, look for the callback type corresponding to this name number = kwargs.get("number") parameters = kwargs.get("parameters") locals = kwargs.get("locals") @@ -404,54 +407,54 @@ class EventHandler(DefaultScript): 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 events: {}".format(kwargs)) + "to call callbacks: {}".format(kwargs)) - event_type = self.get_event_types(obj).get(event_name) - if locals is None and not event_type: - logger.log_err("The event {} for the object {} (typeclass " \ - "{}) can't be found".format(event_name, obj, type(obj))) + 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_type[0]): + for i, variable in enumerate(event[0]): try: locals[variable] = args[i] except IndexError: - logger.log_trace("event {} of {} ({}): need variable " \ - "{} in position {}".format(event_name, obj, + 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()} - events = self.get_events(obj).get(event_name, []) - if event_type: - custom_call = event_type[3] + callbacks = self.get_callbacks(obj).get(callback_name, []) + if event: + custom_call = event[2] if custom_call: - events = custom_call(events, parameters) + callbacks = custom_call(callbacks, parameters) - # Now execute all the valid events linked at this address + # Now execute all the valid callbacks linked at this address self.ndb.current_locals = locals - for i, event in enumerate(events): - if not event["valid"]: + for i, callback in enumerate(callbacks): + if not callback["valid"]: continue - if number is not None and event["number"] != number: + if number is not None and callback["number"] != number: continue try: - exec(event["code"], locals, locals) + exec(callback["code"], locals, locals) except InterruptEvent: return False except Exception: etype, evalue, tb = sys.exc_info() trace = traceback.format_exception(etype, evalue, tb) - number = event["number"] + number = callback["number"] oid = obj.id - logger.log_err("An error occurred during the event {} of " \ - "{} (#{}), number {}\n{}".format(event_name, obj, + logger.log_err("An error occurred during the callback {} of " \ + "{} (#{}), number {}\n{}".format(callback_name, obj, oid, number + 1, "\n".join(trace))) # Inform the 'everror' channel @@ -465,33 +468,54 @@ class EventHandler(DefaultScript): # Try to extract the line try: - line = event["code"].splitlines()[lineno - 1] + line = callback["code"].splitlines()[lineno - 1] except IndexError: continue else: break self.ndb.channel.msg("Error in {} of {} (#{})[{}], line {}:" \ - " {}\n {}".format(event_name, obj, + " {}\n {}".format(callback_name, obj, oid, number + 1, lineno, line, repr(evalue))) return True - def set_task(self, seconds, obj, event_name): + 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. - 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. - Args: seconds (int, float): the delay in seconds from now. obj (Object): the typecalssed object connected to the event. - event_name (str): the event's name. + callback_name (str): the callback's name. - Note: + 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, @@ -514,7 +538,7 @@ class EventHandler(DefaultScript): else: locals[key] = value - self.db.tasks[task_id] = (now + delta, obj, event_name, locals) + self.db.tasks[task_id] = (now + delta, obj, callback_name, locals) delay(seconds, complete_task, task_id) @@ -572,11 +596,12 @@ def complete_task(task_id): """ Mark the task in the event handler as complete. - This function should be called automatically for individual tasks. - 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") @@ -589,5 +614,5 @@ def complete_task(task_id): "found".format(task_id)) return - delta, obj, event_name, locals = script.db.tasks.pop(task_id) - script.call_event(obj, event_name, locals=locals) + 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 index f5b1a40f5..300e41e7a 100644 --- a/evennia/contrib/events/tests.py +++ b/evennia/contrib/events/tests.py @@ -12,8 +12,8 @@ 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 CmdEvent -from evennia.contrib.events.handler import EventsHandler +from evennia.contrib.events.commands import CmdCallback +from evennia.contrib.events.handler import CallbackHandler # Force settings settings.EVENTS_CALENDAR = "standard" @@ -40,125 +40,101 @@ class TestEventHandler(EvenniaTest): def tearDown(self): """Stop the event handler.""" self.handler.stop() - EventsHandler.script = None + 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.events, {}) + 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.assertEqual(self.handler.db.task_id, 0) - self.assertIsNotNone(self.handler.ndb.event_types) - - def test_add(self): - """Add a single event on room1.""" - author = self.char1 - self.handler.add_event(self.room1, "dummy", - "character.db.strength = 50", author=author, valid=True) - event = self.handler.get_events(self.room1).get("dummy") - event = event[0] - self.assertIsNotNone(event) - self.assertEqual(event["obj"], self.room1) - self.assertEqual(event["name"], "dummy") - self.assertEqual(event["number"], 0) - self.assertEqual(event["author"], author) - self.assertEqual(event["valid"], True) - - # Since this event is valid, it shouldn't appear in 'to_valid' - self.assertNotIn((self.room1, "dummy", 0), self.handler.db.to_valid) - - # Run this dummy event - self.char1.db.strength = 10 - locals = {"character": self.char1} - self.assertTrue(self.handler.call_event( - self.room1, "dummy", locals=locals)) - self.assertEqual(self.char1.db.strength, 50) + self.assertIsNotNone(self.handler.ndb.events) def test_add_validation(self): - """Add an event while needing validation.""" + """Add a callback while needing validation.""" author = self.char1 - self.handler.add_event(self.room1, "dummy", + self.handler.add_callback(self.room1, "dummy", "character.db.strength = 40", author=author, valid=False) - event = self.handler.get_events(self.room1).get("dummy") - event = event[0] - self.assertIsNotNone(event) - self.assertEqual(event["author"], author) - self.assertEqual(event["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 event is not valid, it should appear in 'to_valid' + # 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 event (shouldn't do anything) + # Run this dummy callback (shouldn't do anything) self.char1.db.strength = 10 locals = {"character": self.char1} - self.assertTrue(self.handler.call_event( + self.assertTrue(self.handler.call( self.room1, "dummy", locals=locals)) self.assertEqual(self.char1.db.strength, 10) def test_edit(self): - """Test editing an event.""" + """Test editing a callback.""" author = self.char1 - self.handler.add_event(self.room1, "dummy", + self.handler.add_callback(self.room1, "dummy", "character.db.strength = 60", author=author, valid=True) # Edit it right away - self.handler.edit_event(self.room1, "dummy", 0, + self.handler.edit_callback(self.room1, "dummy", 0, "character.db.strength = 65", author=self.char2, valid=True) - # Check that the event was written - event = self.handler.get_events(self.room1).get("dummy") - event = event[0] - self.assertIsNotNone(event) - self.assertEqual(event["author"], author) - self.assertEqual(event["valid"], True) - self.assertEqual(event["updated_by"], self.char2) + # 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 event + # Run this dummy callback self.char1.db.strength = 10 locals = {"character": self.char1} - self.assertTrue(self.handler.call_event( + self.assertTrue(self.handler.call( self.room1, "dummy", locals=locals)) self.assertEqual(self.char1.db.strength, 65) def test_edit_validation(self): - """Edit an event when validation isn't automatic.""" + """Edit a callback when validation isn't automatic.""" author = self.char1 - self.handler.add_event(self.room1, "dummy", + self.handler.add_callback(self.room1, "dummy", "character.db.strength = 70", author=author, valid=True) # Edit it right away - self.handler.edit_event(self.room1, "dummy", 0, + self.handler.edit_callback(self.room1, "dummy", 0, "character.db.strength = 80", author=self.char2, valid=False) - # Run this dummy event (shouldn't do anything) + # Run this dummy callback (shouldn't do anything) self.char1.db.strength = 10 locals = {"character": self.char1} - self.assertTrue(self.handler.call_event( + self.assertTrue(self.handler.call( self.room1, "dummy", locals=locals)) self.assertEqual(self.char1.db.strength, 10) def test_del(self): - """Try to delete an event.""" - # Add 3 events - self.handler.add_event(self.room1, "dummy", + """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_event(self.room1, "dummy", + self.handler.add_callback(self.room1, "dummy", "character.db.strength = 8", author=self.char2, valid=False) - self.handler.add_event(self.room1, "dummy", + self.handler.add_callback(self.room1, "dummy", "character.db.strength = 9", author=self.char1, valid=True) - # Note that the second event isn't valid + # Note that the second callback isn't valid self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid) - # Lock the third event + # Lock the third callback self.handler.db.locked.append((self.room1, "dummy", 2)) - # Delete the first event - self.handler.del_event(self.room1, "dummy", 0) + # Delete the first callback + self.handler.del_callback(self.room1, "dummy", 0) - # The event #1 that was to valid should be #0 now + # 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) @@ -166,104 +142,104 @@ class TestEventHandler(EvenniaTest): 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) event - self.handler.del_event(self.room1, "dummy", 0) + # 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 event + # Call the remaining callback self.char1.db.strength = 10 locals = {"character": self.char1} - self.assertTrue(self.handler.call_event( + self.assertTrue(self.handler.call( self.room1, "dummy", locals=locals)) self.assertEqual(self.char1.db.strength, 9) def test_accept(self): - """Accept an event.""" - # Add 2 events - self.handler.add_event(self.room1, "dummy", + """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_event(self.room1, "dummy", + self.handler.add_callback(self.room1, "dummy", "character.db.strength = 8", author=self.char2, valid=False) - # Note that the second event isn't valid + # Note that the second callback isn't valid self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid) - # Accept the second event - self.handler.accept_event(self.room1, "dummy", 1) - event = self.handler.get_events(self.room1).get("dummy") - event = event[1] - self.assertIsNotNone(event) - self.assertEqual(event["valid"], True) + # 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 event + # Call the dummy callback self.char1.db.strength = 10 locals = {"character": self.char1} - self.assertTrue(self.handler.call_event( + 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 event.""" + """Test to call amore complex callback.""" self.char1.key = "one" self.char2.key = "two" - # Add an event + # Add an callback code = dedent(""" if character.key == "one": character.db.health = 50 else: character.db.health = 0 """.strip("\n")) - self.handler.add_event(self.room1, "dummy", code, + self.handler.add_callback(self.room1, "dummy", code, author=self.char1, valid=True) - # Call the dummy event - self.assertTrue(self.handler.call_event( + # 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_event( + 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.events) + self.assertIsNotNone(self.char1.callbacks) - # Add an event - event = self.room1.events.add("dummy", "pass", author=self.char1, + # Add an callback + callback = self.room1.callbacks.add("dummy", "pass", author=self.char1, valid=True) - self.assertEqual(event.obj, self.room1) - self.assertEqual(event.name, "dummy") - self.assertEqual(event.code, "pass") - self.assertEqual(event.author, self.char1) - self.assertEqual(event.valid, True) - self.assertIn([event], self.room1.events.all().values()) + 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 event - new = self.room1.events.edit("dummy", 0, "character.db.say = True", + # 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.events.all().values()) - self.assertNotIn([event], self.room1.events.all().values()) + self.assertIn([new], self.room1.callbacks.all().values()) + self.assertNotIn([callback], self.room1.callbacks.all().values()) - # Try to call this event - self.assertTrue(self.room1.events.call("dummy", + # Try to call this callback + self.assertTrue(self.room1.callbacks.call("dummy", locals={"character": self.char2})) self.assertTrue(self.char2.db.say) - # Delete the event - self.room1.events.remove("dummy", 0) - self.assertEqual(self.room1.events.all(), {}) + # Delete the callback + self.room1.callbacks.remove("dummy", 0) + self.assertEqual(self.room1.callbacks.all(), {}) -class TestCmdEvent(CommandTest): +class TestCmdCallback(CommandTest): - """Test the @event command.""" + """Test the @callback command.""" def setUp(self): - """Create the event handler.""" - super(TestCmdEvent, self).setUp() + """Create the callback handler.""" + super(TestCmdCallback, self).setUp() self.handler = create_script( "evennia.contrib.events.scripts.EventHandler") @@ -275,32 +251,32 @@ class TestCmdEvent(CommandTest): self.exit.swap_typeclass("evennia.contrib.events.typeclasses.EventExit") def tearDown(self): - """Stop the event handler.""" + """Stop the callback handler.""" self.handler.stop() for script in ScriptDB.objects.filter( db_typeclass_path="evennia.contrib.events.scripts.TimeEventScript"): script.stop() - EventsHandler.script = None - super(TestCmdEvent, self).tearDown() + CallbackHandler.script = None + super(TestCmdCallback, self).tearDown() def test_list(self): - """Test listing events with different rights.""" - table = self.call(CmdEvent(), "out") + """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 event yet) + # 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 event - self.handler.add_event(self.exit, "traverse", "pass", + # Add some callback + self.handler.add_callback(self.exit, "traverse", "pass", author=self.char1, valid=True) - # Try to obtain more details on a specific event on exit - table = self.call(CmdEvent(), "out = traverse") + # 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] @@ -311,7 +287,7 @@ class TestCmdEvent(CommandTest): # Run the same command with char2 # char2 shouldn't see the last column (Valid) - table = self.call(CmdEvent(), "out = traverse", caller=self.char2) + table = self.call(CmdCallback(), "out = traverse", caller=self.char2) lines = table.splitlines()[3:-1] self.assertEqual(len(lines), 1) line = lines[0] @@ -319,18 +295,18 @@ class TestCmdEvent(CommandTest): self.assertEqual(cols[1].strip(), "1") self.assertNotIn(cols[-1].strip(), ("Yes", "No")) - # In any case, display the event - # The last line should be "pass" (the event code) - details = self.call(CmdEvent(), "out = traverse 1") + # 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 event.""" - self.call(CmdEvent(), "/add out = traverse") + """Test to add an callback.""" + self.call(CmdCallback(), "/add out = traverse") editor = self.char1.ndb._eveditor self.assertIsNotNone(editor) - # Edit the event + # Edit the callback editor.update_buffer(dedent(""" if character.key == "one": character.msg("You can pass.") @@ -340,89 +316,89 @@ class TestCmdEvent(CommandTest): """.strip("\n"))) editor.save_buffer() editor.quit() - event = self.exit.events.get("traverse")[0] - self.assertEqual(event.author, self.char1) - self.assertEqual(event.valid, True) - self.assertTrue(len(event.code) > 0) + 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 event won't be validated. - er = self.call(CmdEvent(), "/add out = traverse", caller=self.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 event + # Edit the callback editor.update_buffer(dedent(""" character.msg("No way.") """.strip("\n"))) editor.save_buffer() editor.quit() - event = self.exit.events.get("traverse")[1] - self.assertEqual(event.author, self.char2) - self.assertEqual(event.valid, False) - self.assertTrue(len(event.code) > 0) + 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 event.""" - self.handler.add_event(self.exit, "traverse", "pass", + """Add and remove an callback.""" + self.handler.add_callback(self.exit, "traverse", "pass", author=self.char1, valid=True) - # Try to delete the event - # char2 shouldn't be allowed to do so (that's not HIS event) - self.call(CmdEvent(), "/del out = traverse 1", caller=self.char2) - self.assertTrue(len(self.handler.get_events(self.exit).get( + # 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(CmdEvent(), "/del out = traverse 1") - self.assertTrue(len(self.handler.get_events(self.exit).get( + 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(CmdEvent(), "/add here = time 8:00", caller=self.char2) + 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(CmdEvent(), "/edit here = time 1") + line = self.call(CmdCallback(), "/edit here = time 1") self.assertIsNone(self.char1.ndb._eveditor) - # Try to delete this event while char2 is editing it - line = self.call(CmdEvent(), "/del here = time 1") + # Try to delete this callback while char2 is editing it + line = self.call(CmdCallback(), "/del here = time 1") def test_accept(self): - """Accept an event.""" - self.call(CmdEvent(), "/add here = time 8:00", caller=self.char2) + """Accept an callback.""" + self.call(CmdCallback(), "/add here = time 8:00", caller=self.char2) editor = self.char2.ndb._eveditor self.assertIsNotNone(editor) - # Edit the event + # Edit the callback editor.update_buffer(dedent(""" room.msg_contents("It's 8 PM, everybody up!") """.strip("\n"))) editor.save_buffer() editor.quit() - event = self.room1.events.get("time")[0] - self.assertEqual(event.valid, False) + callback = self.room1.callbacks.get("time")[0] + self.assertEqual(callback.valid, False) - # chars shouldn't be allowed to the event - self.call(CmdEvent(), "/accept here = time 1", caller=self.char2) - event = self.room1.events.get("time")[0] - self.assertEqual(event.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 event - self.call(CmdEvent(), "/accept here = time 1") - event = self.room1.events.get("time")[0] - self.assertEqual(event.valid, True) + # 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 TestDefaultEvents(CommandTest): +class TestDefaultCallbacks(CommandTest): - """Test the default events.""" + """Test the default callbacks.""" def setUp(self): - """Create the event handler.""" - super(TestDefaultEvents, self).setUp() + """Create the callback handler.""" + super(TestDefaultCallbacks, self).setUp() self.handler = create_script( "evennia.contrib.events.scripts.EventHandler") @@ -434,13 +410,13 @@ class TestDefaultEvents(CommandTest): self.exit.swap_typeclass("evennia.contrib.events.typeclasses.EventExit") def tearDown(self): - """Stop the event handler.""" + """Stop the callback handler.""" self.handler.stop() - EventsHandler.script = None - super(TestDefaultEvents, self).tearDown() + CallbackHandler.script = None + super(TestDefaultCallbacks, self).tearDown() def test_exit(self): - """Test the events of an exit.""" + """Test the callbacks of an exit.""" self.char1.key = "char1" code = dedent(""" if character.key == "char1": @@ -452,8 +428,8 @@ class TestDefaultEvents(CommandTest): # Enforce self.exit.destination since swapping typeclass lose it self.exit.destination = self.room2 - # Try the can_traverse event - self.handler.add_event(self.exit, "can_traverse", code, + # 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 @@ -465,15 +441,15 @@ class TestDefaultEvents(CommandTest): caller=self.char2) self.assertIs(self.char2.location, self.room1) - # Try the traverse event - self.handler.del_event(self.exit, "can_traverse", 0) - self.handler.add_event(self.exit, "traverse", "character.msg('Fine!')", + # 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_event(self.exit, "traverse", 0) + self.handler.del_callback(self.exit, "traverse", 0) # Move char1 and char2 back self.char1.location = self.room1 @@ -481,7 +457,7 @@ class TestDefaultEvents(CommandTest): # Test msg_arrive and msg_leave code = 'message = "{character} goes out."' - self.handler.add_event(self.exit, "msg_leave", code, + self.handler.add_callback(self.exit, "msg_leave", code, author=self.char1, valid=True) # Have char1 move through the exit @@ -502,7 +478,7 @@ class TestDefaultEvents(CommandTest): back = create_object("evennia.objects.objects.DefaultExit", key="in", location=self.room2, destination=self.room1) code = 'message = "{character} goes in."' - self.handler.add_event(self.exit, "msg_arrive", code, + self.handler.add_callback(self.exit, "msg_arrive", code, author=self.char1, valid=True) # Have char1 move through the exit diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index f6b372419..3e67ef9c9 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -9,14 +9,175 @@ 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.custom import ( - create_event_type, invalidate_event_type, create_time_event, phrase_event) -from evennia.contrib.events.handler import EventsHandler +from evennia.contrib.events.utils import register_events, time_event, phrase_event +from evennia.contrib.events.handler import CallbackHandler +# 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()' function. + +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 @@ -51,10 +212,10 @@ class EventCharacter(DefaultCharacter): }) if exits: - exits[0].events.call("msg_leave", self, exits[0], + exits[0].callbacks.call("msg_leave", self, exits[0], location, destination, string, mapping) - string = exits[0].events.get_variable("message") - mapping = exits[0].events.get_variable("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 @@ -106,10 +267,10 @@ class EventCharacter(DefaultCharacter): if origin: exits = [o for o in destination.contents if o.location is destination and o.destination is origin] if exits: - exits[0].events.call("msg_arrive", self, exits[0], + exits[0].callbacks.call("msg_arrive", self, exits[0], origin, destination, string, mapping) - string = exits[0].events.get_variable("message") - mapping = exits[0].events.get_variable("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 @@ -137,15 +298,15 @@ class EventCharacter(DefaultCharacter): origin = self.location Room = DefaultRoom if isinstance(origin, Room) and isinstance(destination, Room): - can = self.events.call("can_move", self, + can = self.callbacks.call("can_move", self, origin, destination) if can: - can = origin.events.call("can_move", self, origin) + 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.events.call("can_part", present, self) + can = present.callbacks.call("can_part", present, self) if not can: break @@ -172,13 +333,13 @@ class EventCharacter(DefaultCharacter): destination = self.location Room = DefaultRoom if isinstance(origin, Room) and isinstance(destination, Room): - self.events.call("move", self, origin, destination) - destination.events.call("move", self, origin, destination) + 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.events.call("greet", present, self) + present.callbacks.call("greet", present, self) def at_object_delete(self): """ @@ -187,10 +348,10 @@ class EventCharacter(DefaultCharacter): deletion is aborted. """ - if not self.events.call("can_delete", self): + if not self.callbacks.call("can_delete", self): return False - self.events.call("delete", self) + self.callbacks.call("delete", self) return True def at_post_puppet(self): @@ -207,12 +368,12 @@ class EventCharacter(DefaultCharacter): """ super(EventCharacter, self).at_post_puppet() - self.events.call("puppeted", self) + self.callbacks.call("puppeted", self) # Call the room's puppeted_in event location = self.location if location and isinstance(location, DefaultRoom): - location.events.call("puppeted_in", self, location) + location.callbacks.call("puppeted_in", self, location) def at_pre_unpuppet(self): """ @@ -226,20 +387,121 @@ class EventCharacter(DefaultCharacter): puppeting this Object. """ - self.events.call("unpuppeted", self) + self.callbacks.call("unpuppeted", self) # Call the room's unpuppeted_in event location = self.location if location and isinstance(location, DefaultRoom): - location.events.call("unpuppeted_in", self, location) + 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, @@ -257,7 +519,7 @@ class EventExit(DefaultExit): """ is_character = inherits_from(traversing_object, DefaultCharacter) if is_character: - allow = self.events.call("can_traverse", traversing_object, + allow = self.callbacks.call("can_traverse", traversing_object, self, self.location) if not allow: return @@ -266,14 +528,237 @@ class EventExit(DefaultExit): # After traversing if is_character: - self.events.call("traverse", traversing_object, + 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 @@ -281,10 +766,10 @@ class EventRoom(DefaultRoom): deletion is aborted. """ - if not self.events.call("can_delete", self): + if not self.callbacks.call("can_delete", self): return False - self.events.call("delete", self) + self.callbacks.call("delete", self) return True def at_say(self, speaker, message): @@ -303,463 +788,30 @@ class EventRoom(DefaultRoom): this. """ - allow = self.events.call("can_say", speaker, self, message, + allow = self.callbacks.call("can_say", speaker, self, message, parameters=message) if not allow: return - message = self.events.get_variable("message") + 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.events.call("can_say", speaker, present, + allow = present.callbacks.call("can_say", speaker, present, message, parameters=message) if not allow: return - message = present.events.get_variable("message") + 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.events.call, "say", speaker, self, message, + 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.events.call, "say", speaker, present, message, + delay(0, present.callbacks.call, "say", speaker, present, message, parameters=message) return message - - -class EventObject(DefaultObject): - - """Default object with management of events.""" - - @lazy_property - def events(self): - """Return the EventsHandler.""" - return EventsHandler(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.events.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.events.call("drop", dropper, self) - -## Default events -# Character events -create_event_type(DefaultCharacter, "can_move", ["character", - "origin", "destination"], """ - 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()' function. - - 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. - """) -create_event_type(DefaultCharacter, "can_delete", ["character"], """ - 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. - """) -create_event_type(DefaultCharacter, "can_part", ["character", "departing"], """ - 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. - """) -create_event_type(DefaultCharacter, "can_say", ["speaker", "character", "message"], """ - 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. - """, custom_call=phrase_event) -create_event_type(DefaultCharacter, "delete", ["character"], """ - 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. - """) -invalidate_event_type(DefaultCharacter, "drop") -invalidate_event_type(DefaultCharacter, "get") -create_event_type(DefaultCharacter, "greet", ["character", "newcomer"], """ - 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. - """) -create_event_type(DefaultCharacter, "move", ["character", - "origin", "destination"], """ - 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. - """) -create_event_type(DefaultCharacter, "puppeted", ["character"], """ - 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. - """) -create_event_type(DefaultCharacter, "say", ["speaker", "character", "message"], """ - 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. - """, custom_call=phrase_event) -create_event_type(DefaultCharacter, "time", ["character"], """ - 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. - """, create_time_event) -create_event_type(DefaultCharacter, "unpuppeted", ["character"], """ - 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. - """) - -# Object events -create_event_type(DefaultObject, "drop", ["character", "obj"], """ - 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. - """) -create_event_type(DefaultObject, "get", ["character", "obj"], """ - 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. - """) -create_event_type(DefaultObject, "time", ["object"], """ - 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. - """, create_time_event) - -# Exit events -create_event_type(DefaultExit, "can_traverse", ["character", "exit", "room"], - """ - 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. - """) -invalidate_event_type(DefaultExit, "drop") -invalidate_event_type(DefaultExit, "get") -create_event_type(DefaultExit, "msg_arrive", ["character", "exit", - "origin", "destination", "message", "mapping"], """ - 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. - """) -create_event_type(DefaultExit, "msg_leave", ["character", "exit", - "origin", "destination", "message", "mapping"], """ - 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. - """) -create_event_type(DefaultExit, "time", ["exit"], """ - 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. - """, create_time_event) -create_event_type(DefaultExit, "traverse", ["character", "exit", - "origin", "destination"], """ - 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. - """) - -# Room events -create_event_type(DefaultRoom, "can_delete", ["room"], """ - 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. - """) -create_event_type(DefaultRoom, "can_move", ["character", "room"], """ - 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. - """) -create_event_type(DefaultRoom, "can_say", ["character", "room", "message"], """ - 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. - """, custom_call=phrase_event) -create_event_type(DefaultRoom, "delete", ["room"], """ - 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. - """) -invalidate_event_type(DefaultRoom, "drop") -invalidate_event_type(DefaultRoom, "get") -create_event_type(DefaultRoom, "move", ["character", - "origin", "destination"], """ - 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. - """) -create_event_type(DefaultRoom, "puppeted_in", ["character", "room"], """ - 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. - """) -create_event_type(DefaultRoom, "say", ["character", "room", "message"], """ - 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. - """, custom_call=phrase_event) -create_event_type(DefaultRoom, "time", ["room"], """ - 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. - """, create_time_event) -create_event_type(DefaultRoom, "unpuppeted_in", ["character", "room"], """ - 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. - """) diff --git a/evennia/contrib/events/utils.py b/evennia/contrib/events/utils.py new file mode 100644 index 000000000..bc485b152 --- /dev/null +++ b/evennia/contrib/events/utils.py @@ -0,0 +1,235 @@ +""" +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 + 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. + + 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. + + 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. + + """ + 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 stands 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 From bc9bfb3fa7c48e1b079a1d1634db5190df70e40e Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sat, 22 Apr 2017 19:37:32 -0700 Subject: [PATCH 086/133] Move the interrupting exception of events --- evennia/contrib/events/eventfuncs.py | 2 +- evennia/contrib/events/exceptions.py | 15 --------------- evennia/contrib/events/scripts.py | 3 +-- evennia/contrib/events/utils.py | 12 ++++++++++++ 4 files changed, 14 insertions(+), 18 deletions(-) delete mode 100644 evennia/contrib/events/exceptions.py diff --git a/evennia/contrib/events/eventfuncs.py b/evennia/contrib/events/eventfuncs.py index b72afff86..d81f35cbd 100644 --- a/evennia/contrib/events/eventfuncs.py +++ b/evennia/contrib/events/eventfuncs.py @@ -6,7 +6,7 @@ Eventfuncs are just Python functions that can be used inside of calllbacks. """ from evennia import ObjectDB, ScriptDB -from evennia.contrib.events.exceptions import InterruptEvent +from evennia.contrib.events.utils import InterruptEvent def deny(): """ diff --git a/evennia/contrib/events/exceptions.py b/evennia/contrib/events/exceptions.py deleted file mode 100644 index b33e4a3e2..000000000 --- a/evennia/contrib/events/exceptions.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Module containing the exceptions of the event system. -""" - -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/events/scripts.py b/evennia/contrib/events/scripts.py index e63486db6..b6a4d802f 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -14,9 +14,8 @@ from evennia import logger from evennia.utils.create import create_channel from evennia.utils.dbserialize import dbserialize from evennia.utils.utils import all_from_module, delay -from evennia.contrib.events.exceptions import InterruptEvent from evennia.contrib.events.handler import CallbackHandler -from evennia.contrib.events.utils import get_next_wait, EVENTS +from evennia.contrib.events.utils import get_next_wait, EVENTS, InterruptEvent # Constants RE_LINE_ERROR = re.compile(r'^ File "\", line (\d+)') diff --git a/evennia/contrib/events/utils.py b/evennia/contrib/events/utils.py index bc485b152..de036ca45 100644 --- a/evennia/contrib/events/utils.py +++ b/evennia/contrib/events/utils.py @@ -233,3 +233,15 @@ def phrase_event(callbacks, parameters): 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 From 16cbe2c7812ceb7ef445f74607a15d3fcbe45921 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 23 Apr 2017 17:02:40 -0700 Subject: [PATCH 087/133] Update the documentation of the event system --- evennia/contrib/events/README.md | 347 +++++++++--------- .../events/{handler.py => callbackhandler.py} | 2 +- evennia/contrib/events/commands.py | 8 +- evennia/contrib/events/eventfuncs.py | 58 +-- evennia/contrib/events/scripts.py | 2 +- evennia/contrib/events/tests.py | 2 +- evennia/contrib/events/typeclasses.py | 7 +- evennia/contrib/events/utils.py | 11 +- 8 files changed, 231 insertions(+), 206 deletions(-) rename evennia/contrib/events/{handler.py => callbackhandler.py} (99%) diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md index 53c9c510e..70e78f2e3 100644 --- a/evennia/contrib/events/README.md +++ b/evennia/contrib/events/README.md @@ -6,26 +6,25 @@ This contrib adds the system of events in Evennia, allowing immortals (or other ## 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 two important questions, and answer them for yourself, before deciding to use this system in your game: +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. Is it worth it? This event system isn't some magical feature that would remove the need for the MU*'s development, and empower immortals to create fabulous things without any control. Everything that immortals would be able to do through the event system could be achieved by modifying the source code. Immortals might be familiar with Evennia's design, and could contribute by sending pull requests to your code, for instance. The event system could admittedly earn you time and have immortals adding in special features without the need for complex code. You have to consider, however, if it's worth adding this system in your game. A possible risk is that your immortals will try to do everything though this system and your code will not be updated, while there will still be room to enhance it. -2. Who should use this system? Having arbitrary Python code running cannot be considered a secure feature. You will have to be extremely careful in deciding who can use this system. By default, immortals can create and edit events (these users have access to the `@py` command, which is potentially as dangerous). Builders will not be able to add or edit events, although you can change this setting, to have builders be able to create events, and set their events to require approval by an administrator. You can change permissions (see below for more details on how to do it). You are free to trust or mistrust your builders or other users, just remember that the potential for malign code cannot be restricted. +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 **event types**. An **event type** defines the context in which we would like to call some arbitrary code. For instance, one event type is defined on exits and will fire every time a character traverses through this exit. Event types 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 type. -- An event type should specify a **trigger**, a simple name describing the moment when the event type will be fired. The event type that will be fired every time a character traverses through an exit is called "traverse". Both "event types" and "trigger" can describe the same thing, although the term **trigger** in the rest of the documentation will be used to describe the moment when the event fires. Users of the system will be more interested in knowing what triggers are available for such and such objects, while developers will be there to create event types. -- Individual events can be set on individual objects. They contain the code that will be executed at a specific moment (when a specific action triggers this event type). More than one event can be connected to an object's event type: for instance, several events can be set on the "traverse" event type of a single exit. They will all be called in the order they have been defined. +- 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 type is fired: +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 type "get" is set on objects (on the `DefaultObject` typeclass). +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 type on this object. -4. All events tied to this object's "get" trigger will be executed in order. These events act as functions containing Python code that you can write, using specific variables that will be listed when you edit the event itself. -5. In individual events, 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. +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 an event "get" on the object "a sword", and put in it: +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))) @@ -43,11 +42,11 @@ Being in a separate contrib, the event system isn't installed by default. You n 1. Launch the main script: ```@py ev.create_script("evennia.contrib.events.scripts.EventHandler")``` 2. Set the permissions (optional): - - `EVENTS_WITH_VALIDATION`: a group that can edit events, but will need approval (default to `None`). - - `EVENTS_WITHOUT_VALIDATION`: a group with permission to edit events without need of validation (default to `"immortals"`). - - `EVENTS_VALIDATING`: a group that can validate events (default to `"immortals"`). - - `EVENTS_CALENDAR`: type of the calendar to be used (either `None`, `"standard"`, `"custom"` or a custom callback, default to `None`). -3. Add the `@event` command. + - `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`. @@ -62,21 +61,19 @@ To start the event script, you only need a single command, using `@py`. @py ev.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, event description and so on. You may access it directly, but you will probably use the custom helper functions (see the section on extending the event system). Doing so will also create a `events` handler on all objects (see below for details). +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 events without validation, and who can edit events but needs validation. Validation is a process in which an administrator (or somebody trusted as such) will check the events produced by others and will accept or reject them. If accepted, the events are connected, otherwise they are never run. +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, events can only be created by immortals: no one except the immortals can edit events, and immortals don't need validation. It can easily be changed, either through settings or dynamically by changing permissions of users. - -#### Permissions in settings +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 events, but will need approval. If you set this to `"wizards"`, for instance, users with the permission `"wizards"` will be able to edit events. These events 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 events with validation. -- `EVENTS_WITHOUT_VALIDATION`: this setting defines a permission allowing editing of events without needing validation. By default, this setting is set to `"immortals"`. It means that immortals can edit events, and they will be connected when they leave the editor, without needing approval. -- `EVENTS_VALIDATING`: this last setting defines who can validate events. By default, this is set to `"immortals"`, meaning only immortals can see events needing validation, accept or reject them. +- `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: @@ -89,26 +86,17 @@ EVENTS_WITHOUT_VALIDATION = "immortals" EVENTS_VALIDATING = "immortals" ``` -This set of settings means that: - -1. Wizards can edit events, but they will need to be individually approved before they are connected. Wizards will be able to add whatever they want, but before their code runs, it will have to be checked and approved by an immortal. -2. Immortals can edit events, their work doesn't need to be approved. It is automatically accepted and connected. -3. Immortals can also see events that need approval (these produced by wizards) and accept or reject them. Whenever accepted, the event is connected and will fire without constraint whenever it has to. - 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. -- A special callback to schedule time-related events in a way not supported by the `gametime` utility and the `custom_gametime` contrib (see below). - -#### Permissions on individual users 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 events but not require validation before they are connected. -- `events_validating`: this permission allows this user to run validation checks on events needing to be validated. +- `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 events without needing approval to the player 'kaldara', you might do something like: +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 @@ -116,15 +104,15 @@ To remove this same permission, just use the `/del` switch: @perm/del *kaldara = events_without_validation -The rights to use the `@event` 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 commands (with different switches). +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 `@event` command +### Adding the `@call` command -You also have to add the `@event` command to your Character CmdSet. In your `commands/default_cmdsets`, it might look like this: +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 CmdEvent +from evennia.contrib.events.commands import CmdCallback class CharacterCmdSet(default_cmds.CharacterCmdSet): """ @@ -139,12 +127,12 @@ class CharacterCmdSet(default_cmds.CharacterCmdSet): Populates the cmdset """ super(CharacterCmdSet, self).at_cmdset_creation() - self.add(CmdEvent()) + 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 typeclasses. For instance, in your `typeclasses/characters.py` module, you should change inheritance like this: +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 @@ -156,23 +144,23 @@ 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 `@event` command +## Using the `@call` command -The event system relies, to a great extent, on its `@event` command. Who can execute this command, and who can do what with it, will depend on your set of permissions. +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 event system can be used on most Evennia objects, mostly typeclassed objects (excluding players). The first argument of the `@event` command is the name of the object you want to edit. It can also be used to know what event types are available for this specific object. +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 events and event types +### Examining callbacks and events -To see the event types connected to an object, use the `@event` command and give the name or ID of the object to examine. For instance, @event here` to examine the event types on your current location. Or `@event self` to see the event types on yourself. +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 type (trigger) in the first column. -- The number of events of this name, and the number of total lines of these events in the second column. +- 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 `@event #1` for instance, you might see a table like this: +If you execute `@call #1` for instance, you might see a table like this: ``` +------------------+---------+-----------------------------------------------+ @@ -194,17 +182,17 @@ If you execute `@event #1` for instance, you might see a table like this: +------------------+---------+-----------------------------------------------+ ``` -### Creating a new event +### Creating a new callback -The `/add` switch should be used to add an event. It takes two arguments beyond the object's name/DBREF: +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 trigger of the event to be edited (if not supplied, will display the list of possible triggers, like above). +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 events with parameters later. For the time being, let's try to prevent a character from going through the "north" exit of this room: +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: ``` -@event north +@call north +------------------+---------+-----------------------------------------------+ | Event name | Number | Description | +~~~~~~~~~~~~~~~~~~+~~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+ @@ -219,18 +207,18 @@ We'll see events with parameters later. For the time being, let's try to preven +------------------+---------+-----------------------------------------------+ ``` -If we want to prevent a character from traversing through this exit, the best trigger for us would be "can_traverse". +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 triggers, 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. +> 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: - @event/add north = can_traverse + @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() function to deny the character from +exit. You can use the deny() eventfunc to deny the character from exiting for this time. Variables you can use in this event: @@ -239,7 +227,7 @@ Variables you can use in this event: room: the room in which stands the character before moving. ``` -The section dedicated to [helpers](#the-helper-functions) will elaborate on the `deny()` function and other helpers. 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 `@event/add`, you can type something like: +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: @@ -249,12 +237,12 @@ else: deny() ``` -You can now enter `:wq` to leave the editor by saving the event. +You can now enter `:wq` to leave the editor by saving the callback. -If you enter `@event north`, you should see that "can_traverse" now has an active event. You can use `@event north = can_traverse` to see more details on the connected events: +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: ``` -@event north = can_traverse +@call north = can_traverse +--------------+--------------+----------------+--------------+--------------+ | Number | Author | Updated | Param | Valid | +~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+ @@ -262,15 +250,15 @@ If you enter `@event north`, you should see that "can_traverse" now has an activ +--------------+--------------+----------------+--------------+--------------+ ``` -The left column contains event numbers. You can use them to have even more information on a specific event. Here, for instance: +The left column contains callback numbers. You can use them to have even more information on a specific event. Here, for instance: ``` -@event north = can_traverse 1 -Event can_traverse 1 of north: +@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 event is connected and active. -Event code: +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: @@ -280,30 +268,30 @@ else: Then try to walk through this exit. Do it with another character if possible, too, to see the difference. -### Editing and removing an event +### Editing and removing a callback -You can use the `/edit` switch to the `@event` command to edit an event. You should provide, after the name of the object to edit and the equal sign: +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 events are connected at this location. +2. A number, if several callbacks are connected at this location. -You can type `@event/edit = ` to see the events that are linked at this location. If there is only one event, it will be opened in the editor; if more are defined, you will be asked for a number to provide (for instance, `@event/edit north = can_traverse 2`). +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 `@event` also provides a `/del` switch to remove an event. It takes the same arguments as the `/edit` switch. +The command `@call` also provides a `/del` switch to remove a callback. It takes the same arguments as the `/edit` switch. -When removed, events are logged, so an administrator can retrieve its content, assuming the `/del` was an error. +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 an event, 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). +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 helper functions +### The eventfuncs -In order to make development a little easier, the event system provides helper functions to be used in events themselves. You don't have to use them, they are just shortcuts. +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 -----------|--------------------------|-----------------------------------|-------- @@ -313,29 +301,29 @@ call_event | `(obj, name, seconds=0)` | Call another event. | `cal #### deny -The `deny()` function allows to interrupt the event 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. +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` helper 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 helper in action. +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 events will call others. It is particularly useful for [chained events](#chained-events) that are described in a dedicated section. This helper is used to call another event, immediately or in a defined time. +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 events +### Variables in callbacks -In the Python code you will enter in individual events, you will have access to variable in your locals. These variables will depend on the event, and will be clearly listed when you add or edit it. 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 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 type is fired, all events from this event type are called. Variables are created for each event. Sometimes, however, the event type will execute and then ask for a variable in your event: in other words, some events can alter the actions being performed by changing values of variables. This is always clearly specified in the help of the event. +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 event type "msg_leave" that can be set on exits. This event can alter the message that will be sent to other characters when someone leave through this exit. +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. - @event/add down = msg_leave + @call/add down = msg_leave Which should display: @@ -373,30 +361,32 @@ 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 event, but will read from it when the event has been executed. +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. -### Events with parameters +### Callbacks with parameters -Some events are called without parameter. It has been the case for all examples we have seen before. In some cases, you can create events 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. The event can be configured to fire only when some words are used in the sentence. +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 event with the parameter "one": +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": - @event/add here = say one + @call/add here = say one -This event will only fire when the user says a sentence that contains "one". +This callback will only fire when the user says a sentence that contains "one". -But what if we want to have an event that would fire if the user says 1 or one? We can provide several parameters, separated by a comma. +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. - @event/add here = say 1, one + @call/add here = say 1, one Or, still more keywords: - @event/add here = say 1, one, ground + @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 event). +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! @@ -406,7 +396,7 @@ There is a specific event, on all objects, that can trigger at a specific time. 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): ``` -@event here = time 12:00 +@call here = time 12:00 ``` ```python @@ -414,7 +404,7 @@ For instance, let's add an event on this room that should trigger every day, at room.msg_contents("It's noon, time to have lunch!") ``` -Now, at noon every MUD day, this event will fire. You can use this event on every kind of typeclassed object, to have a specific action done every MUD day at the same time. +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. @@ -433,9 +423,9 @@ Notice that we specify units in the reverse order (year, month, day, hour and mi ### Chained events -Events can call other events, either now or a bit later. It is potentially very powerful. +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` helper function. It takes 2-3 arguments: +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. @@ -443,23 +433,22 @@ To use chained events, just use the `call_event` helper function. It takes 2-3 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. Creating exits (opening its doors), waiting a bit, closing them, rolling around and stopping at a different station. That's quite a complex set of events, as it is, but let's only look at the part that opens and closes the doors: +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: - @event/add here = time 10:00 + @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) -# Open the door 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 -# Create the return exit -back_exit = get(id=24) back_exit.name = "subway" back_exit.location = station back_exit.destination = room @@ -470,16 +459,16 @@ station.msg_contents("The doors of the subway open with a dull clank.") call_event(room, "chain_1", 20) ``` -This event will: +This callback will: -1. Be called at 10:00 AM (specify 22:00 to say 10:00 PM). +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"? - @event/add here = chain_1 + @call/add here = chain_1 ```python # Close the doors @@ -493,71 +482,69 @@ station.msg_content("After a short warning signal, the doors close and the subwa 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 events that call chained events: it isn't impossible for an event 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. +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 events and event types from code, how to create new event types, how to call them in a command, and how to handle specific cases like parameters. +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 and have specific events fired. +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 event types +### Adding new events -Adding new event types should be done below your typeclasses. For instance, if you want to add a new event type on all your rooms, you should probably edit your `typeclasses/rooms.py` module. We'll see how to add a "push" event type to all objeects. To add a new event type, you should use the `create_event_type` function defined in `evennia.contrib.events.custom`. This function takes 4 arguments. +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. -- The class to have these events (defined above). -- The trigger of the event type to add (str). -- The list of variables to be present when calling this events (list of str). -- The help text of this event (str). - -The variables define what will be accessible in the namespace of your event. Here, when we "push" an object, we would like to know what object is pushed, and who has pushed it (we'll limit this command to characters). You can edit `typeclasses/objects.py` to modify/add the following lines: +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.custom import create_event_type, connect_event_types +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. + """ - -# Object events -create_event_type(Object, "push", ["character", "obj"], """ - 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. -""") - -# Force-update the new event types -connect_event_types() + _events = { + "push": (["character", "obj"], EVENT_PUSH), + } ``` -Here we have set: +- 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). -1. The typeclass (here, `Object`), meaning that this event will be accessible to all instances of `Object` or a child class. -2. `"push"` as the trigger (the name of the event type). -3. Two variables ("character" and "obj") that will be accessible in our event namespace. -4. A longer help text to describe more in details when this event will fire. It's best to keep this format as much as possible: a single line to briefly describe the event, a longer explanation on several lines, and the list of variables of this event. - -> It's best to call `connect_event_types()` after having defined new event types. It can be kept for the very last line of the file. The event system doesn't automatically integrate new event types, this function is to force it to do so. - -If you save this code and reload your game, you should see the new event type if you enter the `@event` command with an object as argument. +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 `events` 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 type on this object. +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 `events.call` method in an object. It takes as argument: +To call an event, use the `callbacks.call` method in an object. It takes as argument: -- The name of the event type 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 event types](#adding-new-event-types). +- 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 type on all objects, called "push". This event type 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 type. +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 @@ -589,27 +576,27 @@ class CmdPush(Command): self.msg("You push {}.".format(obj.get_display_name(self.caller))) - # Call the "push" event type of this object - obj.events.call("push", self.caller, obj) + # Call the "push" event of this object + obj.callbacks.call("push", self.caller, obj) ``` -Here we use `events.call` with the following arguments: +Here we use `callbacks.call` with the following arguments: -- `"push"`: the name of the event type to be called. +- `"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" event 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). +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 type and the "push" command), let us create a simple object: +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. - @event/add rock = push + @call/add rock = push -In the event you could write: +In the callback you could write: ```python from random import randint @@ -621,26 +608,58 @@ if number == 6: 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 helper functions +### Adding new eventfuncs -Helper functions, like `deny(), are defined in `contrib/events/helpers.py`. You can add your own helpers by creating a file named `event_helpers.py` in your `world` directory. The functions defined in this file will be added as helpers. +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 helper functions in another location, or even in several locations. To do so, edit the `EVENTS_HELPERS_LOCATIONS` 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: +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 -EVENTS_HELPERS_LOCATIONS = [ - "world.events.helpers", +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 events 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`): +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 `@event` command, to debug), but no event will be called automatically. +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/handler.py b/evennia/contrib/events/callbackhandler.py similarity index 99% rename from evennia/contrib/events/handler.py rename to evennia/contrib/events/callbackhandler.py index fdec51e0e..9d6c0b111 100644 --- a/evennia/contrib/events/handler.py +++ b/evennia/contrib/events/callbackhandler.py @@ -1,5 +1,5 @@ """ -Module containing the EventHandler for individual objects. +Module containing the CallbackHandler for individual objects. """ from collections import namedtuple diff --git a/evennia/contrib/events/commands.py b/evennia/contrib/events/commands.py index 4f9295843..ddc42d42f 100644 --- a/evennia/contrib/events/commands.py +++ b/evennia/contrib/events/commands.py @@ -58,7 +58,7 @@ 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 callbacks to be called) using the /tasks switch. +(chained events to be called) using the /tasks switch. """ VALIDATOR_TEXT = """ @@ -264,7 +264,7 @@ class CmdCallback(COMMAND_DEFAULT_CLASS): 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 callback."))[1] + description = types.get(name, (None, "Chained event."))[1] description = description.strip("\n").splitlines()[0] table.add_row(name, no, description) @@ -282,7 +282,7 @@ class CmdCallback(COMMAND_DEFAULT_CLASS): "typeclass {}.".format(callback_name, obj, type(obj))) return - definition = types.get(callback_name, (None, "Chained callback")) + definition = types.get(callback_name, (None, "Chained event.")) description = definition[1] self.msg(raw(description.strip("\n"))) @@ -352,7 +352,7 @@ class CmdCallback(COMMAND_DEFAULT_CLASS): self.handler.db.locked.append((obj, callback_name, number)) # Check the definition of the callback - definition = types.get(callback_name, (None, "Chained callback")) + definition = types.get(callback_name, (None, "Chained event.")) description = definition[1] self.msg(raw(description.strip("\n"))) diff --git a/evennia/contrib/events/eventfuncs.py b/evennia/contrib/events/eventfuncs.py index d81f35cbd..cf323771e 100644 --- a/evennia/contrib/events/eventfuncs.py +++ b/evennia/contrib/events/eventfuncs.py @@ -12,11 +12,12 @@ def deny(): """ Deny, that is stop, the event here. - 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. + 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 @@ -25,17 +26,6 @@ def get(**kwargs): """ Return an object with the given search option or None if None is found. - 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 event 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. - Kwargs: Any searchable data or property (id, db_key, db_location...). @@ -43,6 +33,18 @@ def get(**kwargs): 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) @@ -55,32 +57,32 @@ def call_event(obj, event_name, seconds=0): """ Call the specified event in X seconds. - This helper 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. - 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. - Note: - Chained events are designed for this very purpose: they + 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.events).script + 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.events.call(event_name, locals=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 index b6a4d802f..66a6cf164 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -14,7 +14,7 @@ from evennia import logger from evennia.utils.create import create_channel from evennia.utils.dbserialize import dbserialize from evennia.utils.utils import all_from_module, delay -from evennia.contrib.events.handler import CallbackHandler +from evennia.contrib.events.callbackhandler import CallbackHandler from evennia.contrib.events.utils import get_next_wait, EVENTS, InterruptEvent # Constants diff --git a/evennia/contrib/events/tests.py b/evennia/contrib/events/tests.py index 300e41e7a..c8d92bd3a 100644 --- a/evennia/contrib/events/tests.py +++ b/evennia/contrib/events/tests.py @@ -13,7 +13,7 @@ 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.handler import CallbackHandler +from evennia.contrib.events.callbackhandler import CallbackHandler # Force settings settings.EVENTS_CALENDAR = "standard" diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index 3e67ef9c9..e8b0bdb6f 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -9,8 +9,8 @@ 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 -from evennia.contrib.events.handler import CallbackHandler # Character help CHARACTER_CAN_DELETE = """ @@ -28,7 +28,7 @@ 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()' function. +using the 'deny()' eventfunc. Variables you can use in this event: character: the character connected to this event. @@ -782,6 +782,9 @@ class EventRoom(DefaultRoom): 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 diff --git a/evennia/contrib/events/utils.py b/evennia/contrib/events/utils.py index de036ca45..9ecb3544e 100644 --- a/evennia/contrib/events/utils.py +++ b/evennia/contrib/events/utils.py @@ -90,15 +90,16 @@ def get_next_wait(format): Args: format (str): a time format matching the set calendar. - 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. - 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: @@ -157,7 +158,7 @@ def time_event(obj, event_name, number, parameters): Create a time-related event. Args: - obj (Object): the object on which stands the event. + 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. From dc5d790b44c6f3a561c4e8e69b05be87cd25ec1e Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 23 Apr 2017 17:14:57 -0700 Subject: [PATCH 088/133] Update eventfuncs to try to load world/eventfuncs.py if exists --- evennia/contrib/events/scripts.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 66a6cf164..ba03714bd 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -13,7 +13,7 @@ from evennia import DefaultObject, DefaultScript, ChannelDB, ScriptDB from evennia import logger from evennia.utils.create import create_channel from evennia.utils.dbserialize import dbserialize -from evennia.utils.utils import all_from_module, delay +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 @@ -69,9 +69,10 @@ class EventHandler(DefaultScript): self.ndb.current_locals = {} self.ndb.fresh_locals = {} addresses = ["evennia.contrib.events.eventfuncs"] - addresses.extend(getattr(settings, "EVENTFUNCS_LOCATIONS", [])) + addresses.extend(getattr(settings, "EVENTFUNCS_LOCATIONS", ["world.eventfuncs"])) for address in addresses: - self.ndb.fresh_locals.update(all_from_module(address)) + if pypath_to_realpath(address): + self.ndb.fresh_locals.update(all_from_module(address)) # Restart the delayed tasks now = datetime.now() From 2d250c0f962647ac34158ea4ca10e1b87d4dbc6d Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 24 Apr 2017 21:38:00 -0700 Subject: [PATCH 089/133] Fix a bug in the TimeEvent scripts --- evennia/contrib/events/scripts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index ba03714bd..e1c01891e 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -583,12 +583,12 @@ class TimeEventScript(DefaultScript): if self.db.event_name and self.db.number is not None: obj = self.obj - if not obj.events: + if not obj.callbacks: return event_name = self.db.event_name number = self.db.number - obj.events.call(event_name, obj, number=number) + obj.callbacks.call(event_name, obj, number=number) # Functions to manipulate tasks From 5a443d206cb98ba84100139bc3005870069e11d8 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 26 Apr 2017 20:00:53 -0700 Subject: [PATCH 090/133] Add the new handling of errors in callbacks --- evennia/contrib/events/scripts.py | 86 +++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index e1c01891e..7c6f236d1 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -11,6 +11,7 @@ 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 @@ -451,35 +452,68 @@ class EventHandler(DefaultScript): except Exception: etype, evalue, tb = sys.exc_info() trace = traceback.format_exception(etype, evalue, tb) - number = callback["number"] - 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))) - - # Inform the 'everror' channel - 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 = callback["code"].splitlines()[lineno - 1] - except IndexError: - continue - else: - break - - self.ndb.channel.msg("Error in {} of {} (#{})[{}], line {}:" \ - " {}\n {}".format(callback_name, obj, - oid, number + 1, lineno, line, repr(evalue))) + 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. From 1a8c07d0b688924f07037e732237ff95a4ff0c9c Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 30 Apr 2017 17:59:38 -0700 Subject: [PATCH 091/133] Fix unittests for the event system --- evennia/contrib/events/tests.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/evennia/contrib/events/tests.py b/evennia/contrib/events/tests.py index c8d92bd3a..c6ca6aaf6 100644 --- a/evennia/contrib/events/tests.py +++ b/evennia/contrib/events/tests.py @@ -18,6 +18,9 @@ from evennia.contrib.events.callbackhandler import CallbackHandler # Force settings settings.EVENTS_CALENDAR = "standard" +# Constants +OLD_EVENTS = {} + class TestEventHandler(EvenniaTest): """ @@ -30,6 +33,10 @@ class TestEventHandler(EvenniaTest): 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") @@ -39,6 +46,8 @@ class TestEventHandler(EvenniaTest): 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() @@ -243,6 +252,10 @@ class TestCmdCallback(CommandTest): 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") @@ -252,6 +265,8 @@ class TestCmdCallback(CommandTest): 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"): @@ -402,6 +417,10 @@ class TestDefaultCallbacks(CommandTest): 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") @@ -411,6 +430,8 @@ class TestDefaultCallbacks(CommandTest): 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() From 1356e569b5cf6c92e6ce246cf9e07dcabe123c2a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 May 2017 14:19:06 +0200 Subject: [PATCH 092/133] Clean up some readme formatting. --- evennia/contrib/events/README.md | 434 ++++++++++++++++++++++--------- 1 file changed, 317 insertions(+), 117 deletions(-) diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md index 70e78f2e3..4692f8d74 100644 --- a/evennia/contrib/events/README.md +++ b/evennia/contrib/events/README.md @@ -2,27 +2,49 @@ 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. +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: +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. +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. +- 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: +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. +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: @@ -37,15 +59,19 @@ When you pick up this object you should see something like: ## Installation -Being in a separate contrib, the event system isn't installed by default. You need to do it manually, following these steps: +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: - ```@py ev.create_script("evennia.contrib.events.scripts.EventHandler")``` + ```@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_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`). + - `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`. @@ -59,21 +85,41 @@ The following sections describe in details each step of the installation. To start the event script, you only need a single command, using `@py`. - @py ev.create_script("evennia.contrib.events.scripts.EventHandler") + @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). +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. +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. +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: +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. +- `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: @@ -86,17 +132,25 @@ 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: +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. +- `"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. +- `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: +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 @@ -104,11 +158,15 @@ 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). +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: +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 @@ -132,7 +190,9 @@ class CharacterCmdSet(default_cmds.CharacterCmdSet): ### 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: +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 @@ -142,22 +202,31 @@ 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. +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 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. +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. +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. +- 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: @@ -184,12 +253,15 @@ If you execute `@call #1` for instance, you might see a table like this: ### Creating a new callback -The `/add` switch should be used to add a callback. It takes two arguments beyond the object's name/DBREF: +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). +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: +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 @@ -207,27 +279,32 @@ We'll see callbacks with parameters later. For the time being, let's try to pre +------------------+---------+-----------------------------------------------+ ``` -If we want to prevent a character from traversing through this exit, the best event for us would be "can_traverse". +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. +> 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: + - 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: @@ -239,7 +316,8 @@ else: 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: +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 @@ -250,7 +328,8 @@ If you enter `@call north`, you should see that "can_traverse" now has an active +--------------+--------------+----------------+--------------+--------------+ ``` -The left column contains callback numbers. You can use them to have even more information on a specific event. Here, for instance: +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 @@ -266,32 +345,43 @@ else: deny() ``` -Then try to walk through this exit. Do it with another character if possible, too, to see the difference. +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: +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`). +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. +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. +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). +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 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. +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 -----------|--------------------------|-----------------------------------|-------- @@ -301,27 +391,45 @@ call_event | `(obj, name, seconds=0)` | Call another event. | `cal #### 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. +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. +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. +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. +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). +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 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. +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. +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 @@ -361,19 +469,27 @@ 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. +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. +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": +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. +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 @@ -381,34 +497,50 @@ 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). +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. +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. +> 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! +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. +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): +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 -``` + @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. +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. +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). +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: @@ -417,9 +549,13 @@ Some examples of syntax: - `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. +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. +> You can use chained events (see below) in conjunction with time-related events to create more +random or frequent actions in events. ### Chained events @@ -431,9 +567,14 @@ To use chained events, just use the `call_event` eventfunc. It takes 2-3 argume - 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". +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: +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 @@ -444,6 +585,7 @@ Rather than a long explanation, let's look at an example: a subway that will go 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"] @@ -452,9 +594,11 @@ 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) ``` @@ -462,7 +606,8 @@ 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. +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. @@ -480,25 +625,43 @@ room.msg_content("After a short warning signal, the doors close and the subway b 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. +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. +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. +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. +> 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. +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. +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. +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: +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 @@ -525,26 +688,41 @@ class Object(EventObject): } ``` -- 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 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). +- 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. +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. +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). +- 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. +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 @@ -586,11 +764,13 @@ Here we use `callbacks.call` with the following arguments: - `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). +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: +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. @@ -606,13 +786,19 @@ 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". +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. +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: +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 = [ @@ -622,12 +808,21 @@ EVENTFUNCS_LOCATIONS = [ ### 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). +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). +- 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. +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. @@ -644,22 +839,27 @@ class SomeTypeclass: } ``` -When you call an event using the `obj.callbacks.call` method, you should also provide the parameter, using the `parameters` keyword: +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. +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`): +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. - +The event system will still be accessible (you will have access to the `@call` command, to debug), +but no event will be called automatically. From 4f76d255fabd125e5b750af3d1f08815526fd179 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 May 2017 14:42:52 +0200 Subject: [PATCH 093/133] Add warning about starting without main script. --- evennia/contrib/events/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/events/README.md b/evennia/contrib/events/README.md index 4692f8d74..d72bf1647 100644 --- a/evennia/contrib/events/README.md +++ b/evennia/contrib/events/README.md @@ -62,7 +62,7 @@ When you pick up this object you should see something like: 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: +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 @@ -81,6 +81,10 @@ manually, following these steps: 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`. From 6ea13dbc96539cd8276e6b7c5a25ffbb6cf1bdd1 Mon Sep 17 00:00:00 2001 From: Tehom Date: Sun, 7 May 2017 02:12:58 -0400 Subject: [PATCH 094/133] Check for instance in cache to have the appropriate foreignkey if we're called from a related manager. Stops us from retrieving any object by ID, whether it was related to us or not. --- evennia/utils/idmapper/manager.py | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 02a35972afc28aff81dc1153db0f5eb572c7f085 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 8 May 2017 19:18:54 -0700 Subject: [PATCH 095/133] Remove the over-incrementing task ID --- evennia/contrib/events/scripts.py | 9 ++++++--- evennia/contrib/events/tests.py | 1 - 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 7c6f236d1..a61d308e5 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -45,7 +45,6 @@ class EventHandler(DefaultScript): self.db.locked = [] # Tasks - self.db.task_id = 0 self.db.tasks = {} def at_start(self): @@ -559,8 +558,12 @@ class EventHandler(DefaultScript): """ now = datetime.now() delta = timedelta(seconds=seconds) - task_id = self.db.task_id - self.db.task_id += 1 + + # 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 = {} diff --git a/evennia/contrib/events/tests.py b/evennia/contrib/events/tests.py index c6ca6aaf6..c2a8bd4b0 100644 --- a/evennia/contrib/events/tests.py +++ b/evennia/contrib/events/tests.py @@ -58,7 +58,6 @@ class TestEventHandler(EvenniaTest): self.assertEqual(self.handler.db.to_valid, []) self.assertEqual(self.handler.db.locked, []) self.assertEqual(self.handler.db.tasks, {}) - self.assertEqual(self.handler.db.task_id, 0) self.assertIsNotNone(self.handler.ndb.events) def test_add_validation(self): From 6db118ac6177aee7c072de40552f00c4e48e7b0b Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 11 May 2017 19:30:54 +0200 Subject: [PATCH 096/133] Add listing of home to examine command. --- evennia/commands/default/building.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 02b09c015..9045b1155 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1927,6 +1927,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: From 112e457cc33917fe8552c227d3018bb5759aaef3 Mon Sep 17 00:00:00 2001 From: Alex Jaffe Date: Sun, 14 May 2017 16:34:03 +0800 Subject: [PATCH 097/133] Fixed a bug in which creating a SimpleDoor caused the next exit created to fail silently. --- evennia/contrib/simpledoor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 From f150d8bae53da352d5b48b677c68ddd66665f3ea Mon Sep 17 00:00:00 2001 From: Tehom Date: Sat, 20 May 2017 21:33:57 -0400 Subject: [PATCH 098/133] Add Evennia logfile class based on Twisted LogFile for automatic rotating channel logs. Append tail end of logs when rotating, customizable with settings. --- evennia/settings_default.py | 4 ++++ evennia/utils/logger.py | 28 +++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index c5844205c..c472268a7 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 +NUM_LOG_TAIL_LINES = 20 +# Max size of channel log files before they rotate +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' diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index a46c549de..a22e313bc 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -19,8 +19,9 @@ 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 +from django.conf import settings _LOGDIR = None @@ -153,6 +154,27 @@ log_depmsg = log_dep # Arbitrary file logger +class EvenniaLogFile(logfile.LogFile): + num_lines_to_append = settings.NUM_LOG_TAIL_LINES + + def rotate(self): + append_tail = self.num_lines_to_append > 0 + print "append_tail is %s" % append_tail + if not append_tail: + logfile.LogFile.rotate(self) + return + lines = tail_log_file(self.path, 0, self.num_lines_to_append) + print "lines is %s" % lines + logfile.LogFile.rotate(self) + for line in lines: + self.write(line) + + def seek(self, *args, **kwargs): + return self._file.seek(*args, **kwargs) + + def readlines(self, *args, **kwargs): + return self._file.readlines(*args, **kwargs) + _LOG_FILE_HANDLES = {} # holds open log handles @@ -164,7 +186,6 @@ def _open_log_file(filename): """ global _LOG_FILE_HANDLES, _LOGDIR if not _LOGDIR: - from django.conf import settings _LOGDIR = settings.LOG_DIR filename = os.path.join(_LOGDIR, filename) @@ -173,7 +194,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=settings.LOG_ROTATE_SIZE) + # filehandle = open(filename, "a+") # append mode + reading _LOG_FILE_HANDLES[filename] = filehandle return filehandle except IOError: From 6e2c10b008783ce13042f42b6fe3fa1b730cca81 Mon Sep 17 00:00:00 2001 From: Tehom Date: Sat, 20 May 2017 22:18:26 -0400 Subject: [PATCH 099/133] Remove print statements I had left in during debugging. Oops. --- evennia/utils/logger.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index a22e313bc..70bf223fe 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -159,12 +159,10 @@ class EvenniaLogFile(logfile.LogFile): def rotate(self): append_tail = self.num_lines_to_append > 0 - print "append_tail is %s" % append_tail if not append_tail: logfile.LogFile.rotate(self) return lines = tail_log_file(self.path, 0, self.num_lines_to_append) - print "lines is %s" % lines logfile.LogFile.rotate(self) for line in lines: self.write(line) From f9c369f86912aebfd835ca83f4344dc265d4ce22 Mon Sep 17 00:00:00 2001 From: Tehom Date: Sat, 20 May 2017 23:18:26 -0400 Subject: [PATCH 100/133] Add method to subscription handler to check online members, and put check in Channel typeclass for using this based on settings option. --- evennia/comms/comms.py | 9 +++++++-- evennia/comms/models.py | 18 ++++++++++++++++++ evennia/settings_default.py | 2 ++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index f50d3bf4a..dd6090b37 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -2,6 +2,7 @@ Base typeclass for in-game Channels. """ +from django.conf import settings from evennia.typeclasses.models import TypeclassBase from evennia.comms.models import TempMsg, ChannelDB @@ -246,7 +247,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 @@ -262,7 +267,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): logger.log_file(msgobj.message, self.attributes.get("log_file") or "channel_%s.log" % self.key) def msg(self, msgobj, header=None, senders=None, sender_strings=None, - keep_log=None, online=False, emit=False, external=False): + keep_log=None, online=settings.CHANNELS_MSG_OFFLINE, emit=False, external=False): """ Send the given message to all players connected to channel. Note that no permission-checking is done here; it is assumed to have been diff --git a/evennia/comms/models.py b/evennia/comms/models.py index ce5f3d739..c7b5e8d7b 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -538,6 +538,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. diff --git a/evennia/settings_default.py b/evennia/settings_default.py index c5844205c..24b68aa8a 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -125,6 +125,8 @@ 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 +# whether channels attempt to message offline players by default +CHANNELS_MSG_OFFLINE = False # 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' From 27c72d789978b3c87cfa674a7ea6f97725206691 Mon Sep 17 00:00:00 2001 From: Tehom Date: Sat, 20 May 2017 23:33:39 -0400 Subject: [PATCH 101/133] Decided to remove setting because it seemed unnecessary when this was the purported default behavior anyway. It makes sense that if they want to send to offline, they'd override the option to be True in their channel typeclasses. At least that's my thinking, maybe I'm off base. Remove docstr line that says online arg is not currently used. --- evennia/comms/comms.py | 5 +---- evennia/settings_default.py | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index dd6090b37..fd51c03d7 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -2,8 +2,6 @@ Base typeclass for in-game Channels. """ -from django.conf import settings - from evennia.typeclasses.models import TypeclassBase from evennia.comms.models import TempMsg, ChannelDB from evennia.comms.managers import ChannelManager @@ -240,7 +238,6 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): Args: msgobj (Msg or TempMsg): Message to distribute. online (bool): Only send to receivers who are actually online - (not currently used): Notes: This is also where logging happens, if enabled. @@ -267,7 +264,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): logger.log_file(msgobj.message, self.attributes.get("log_file") or "channel_%s.log" % self.key) def msg(self, msgobj, header=None, senders=None, sender_strings=None, - keep_log=None, online=settings.CHANNELS_MSG_OFFLINE, emit=False, external=False): + keep_log=None, online=False, emit=False, external=False): """ Send the given message to all players connected to channel. Note that no permission-checking is done here; it is assumed to have been diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 24b68aa8a..c5844205c 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -125,8 +125,6 @@ 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 -# whether channels attempt to message offline players by default -CHANNELS_MSG_OFFLINE = False # 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' From 4605b3128ec7108a471d9f63fb23839d4a72bd90 Mon Sep 17 00:00:00 2001 From: Tehom Date: Sun, 21 May 2017 03:29:33 -0400 Subject: [PATCH 102/133] Add docstr to class and methods, adjust names of settings to be more clearly modifying channel logs. --- evennia/settings_default.py | 4 ++-- evennia/utils/logger.py | 31 +++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index c472268a7..f94bf5d3e 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -126,9 +126,9 @@ LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log') # loose log info. CYCLE_LOGFILES = True # Number of lines to append to rotating channel logs when they rotate -NUM_LOG_TAIL_LINES = 20 +CHANNEL_LOG_NUM_TAIL_LINES = 20 # Max size of channel log files before they rotate -LOG_ROTATE_SIZE = 1000000 +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' diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index 70bf223fe..bde064992 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -155,9 +155,19 @@ log_depmsg = log_dep # Arbitrary file logger class EvenniaLogFile(logfile.LogFile): - num_lines_to_append = settings.NUM_LOG_TAIL_LINES + """ + 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. + """ + num_lines_to_append = settings.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) @@ -168,9 +178,26 @@ class EvenniaLogFile(logfile.LogFile): 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 @@ -192,7 +219,7 @@ def _open_log_file(filename): return _LOG_FILE_HANDLES[filename] else: try: - filehandle = EvenniaLogFile.fromFullPath(filename, rotateLength=settings.LOG_ROTATE_SIZE) + filehandle = EvenniaLogFile.fromFullPath(filename, rotateLength=settings.CHANNEL_LOG_ROTATE_SIZE) # filehandle = open(filename, "a+") # append mode + reading _LOG_FILE_HANDLES[filename] = filehandle return filehandle From 2bea778181f9f81e7eba2a77b07521f07f9dbb33 Mon Sep 17 00:00:00 2001 From: Tehom Date: Sun, 21 May 2017 06:59:54 -0400 Subject: [PATCH 103/133] Move settings to be local imports to prevent conflicts before django is fully loaded --- evennia/utils/logger.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index bde064992..adf960d04 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -21,7 +21,6 @@ from datetime import datetime from traceback import format_exc from twisted.python import log, logfile from twisted.internet.threads import deferToThread -from django.conf import settings _LOGDIR = None @@ -161,6 +160,7 @@ class EvenniaLogFile(logfile.LogFile): lines of the previous log to the start of the new log, in order to preserve a continuous chat history for channel log files. """ + from django.conf import settings num_lines_to_append = settings.CHANNEL_LOG_NUM_TAIL_LINES def rotate(self): @@ -209,6 +209,7 @@ def _open_log_file(filename): handle. Will create a new file in the log dir if one didn't exist. """ + from django.conf import settings global _LOG_FILE_HANDLES, _LOGDIR if not _LOGDIR: _LOGDIR = settings.LOG_DIR From 6370c377d5b7e2f6fe10497b971c10e9d25581a8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 21 May 2017 19:24:54 +0200 Subject: [PATCH 104/133] Add cache optimization to variable imports in logger. --- evennia/utils/logger.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index adf960d04..9048c5de1 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -24,7 +24,9 @@ from twisted.internet.threads import deferToThread _LOGDIR = None +_LOG_ROTATE_SIZE = None _TIMEZONE = None +_CHANNEL_LOG_NUM_TAIL_LINES = None def timeformat(when=None): @@ -160,8 +162,13 @@ class EvenniaLogFile(logfile.LogFile): lines of the previous log to the start of the new log, in order to preserve a continuous chat history for channel log files. """ - from django.conf import settings - num_lines_to_append = settings.CHANNEL_LOG_NUM_TAIL_LINES + # 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): """ @@ -209,10 +216,13 @@ def _open_log_file(filename): handle. Will create a new file in the log dir if one didn't exist. """ - from django.conf import settings - 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: @@ -220,7 +230,7 @@ def _open_log_file(filename): return _LOG_FILE_HANDLES[filename] else: try: - filehandle = EvenniaLogFile.fromFullPath(filename, rotateLength=settings.CHANNEL_LOG_ROTATE_SIZE) + filehandle = EvenniaLogFile.fromFullPath(filename, rotateLength=_LOG_ROTATE_SIZE) # filehandle = open(filename, "a+") # append mode + reading _LOG_FILE_HANDLES[filename] = filehandle return filehandle From 1248428d132fde1b975678b53e22c1ca68a73a43 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 22 May 2017 08:21:25 +0200 Subject: [PATCH 105/133] Update coveralls to exclude checking some types of non-code files. Resolves #1318. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 13de1f8af877629d7c8f64ffc4ccd19a13e1bf78 Mon Sep 17 00:00:00 2001 From: Tehom Date: Mon, 29 May 2017 05:50:13 -0400 Subject: [PATCH 106/133] Change portal's website class to be the one defined for evennia, which suppresses logging unless DEBUG is on. --- evennia/server/portal/portal.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) From 4b5ee7729256a25102fa8df7937b860c46dbc6bd Mon Sep 17 00:00:00 2001 From: Tehom Date: Tue, 30 May 2017 03:45:55 -0400 Subject: [PATCH 107/133] Attempt to address crash bug when stopping threadpool if there are pending web requests. Return a DeferredList to be checked in server shutdown. --- evennia/server/server.py | 9 ++++++++- evennia/server/webserver.py | 26 +++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/evennia/server/server.py b/evennia/server/server.py index b5a1aff51..c8c75835e 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -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 @@ -348,6 +349,10 @@ class Evennia(object): from evennia.server.models import ServerConfig from evennia.utils import gametime as _GAMETIME_MODULE + if WEBSERVER_ENABLED: + # finish all pending web requests. Otherwise stopping threadpool will cause deadlock. + yield self.web_root.get_pending_requests() + if mode == 'reload': # call restart hooks ServerConfig.objects.conf("server_restart_mode", "reload") @@ -533,11 +538,13 @@ if WEBSERVER_ENABLED: # recognized by Django threads = threadpool.ThreadPool(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..7144c481a 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -28,6 +28,7 @@ from evennia.utils import logger _UPSTREAM_IPS = settings.UPSTREAM_IPS _DEBUG = settings.DEBUG + # # X-Forwarded-For Handler # @@ -117,6 +118,8 @@ class DjangoWebRoot(resource.Resource): understands by tweaking the way child instancee ars recognized. """ + open_requests = [] + def __init__(self, pool): """ Setup the django+twisted resource. @@ -128,6 +131,21 @@ class DjangoWebRoot(resource.Resource): resource.Resource.__init__(self) self.wsgi_resource = WSGIResource(reactor, pool, WSGIHandler()) + def get_pending_requests(self): + """ + Converts our open_requests list of deferreds into a DeferredList + + Returns: + d_list (deferred): A DeferredList object of all our requests + """ + from twisted.internet import defer + return defer.DeferredList(self.open_requests, consumeErrors=True) + + def _decrement_requests(self, *args, **kwargs): + deferred = kwargs.get('deferred', None) + if deferred in self.open_requests: + self.open_requests.remove(deferred) + def getChild(self, path, request): """ To make things work we nudge the url tree to make this the @@ -140,8 +158,12 @@ class DjangoWebRoot(resource.Resource): """ path0 = request.prepath.pop(0) request.postpath.insert(0, path0) + deferred = request.notifyFinish() + self.open_requests.append(deferred) + deferred.addBoth(self._decrement_requests, deferred=deferred) return self.wsgi_resource + # # Site with deactivateable logging # @@ -151,11 +173,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 # From 96ab620618e29e0478669fa5f076269e856f4e83 Mon Sep 17 00:00:00 2001 From: Tehom Date: Wed, 31 May 2017 07:14:25 -0400 Subject: [PATCH 108/133] Add extra checking for additional requests made while we were in the process of stopping reactor, change from callLater to deferLater to be compliant with inlinecallbacks. --- evennia/server/server.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/evennia/server/server.py b/evennia/server/server.py index c8c75835e..cde1067dc 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -397,8 +397,15 @@ class Evennia(object): # this will also send a reactor.stop signal, so we set a # flag to avoid loops. self.shutdown_complete = True - # kill the server - reactor.callLater(1, reactor.stop) + if WEBSERVER_ENABLED: + # Just to be extra sure, get all pending requests that might have occurred after we started + d = self.web_root.get_pending_requests() + d.addCallback(lambda _: reactor.stop()) + from twisted.internet import task + yield task.deferLater(reactor, 1, d.callback, None) + else: + # kill the server + reactor.callLater(1, reactor.stop) # we make sure the proper gametime is saved as late as possible ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.runtime()) From 31e7aa125339d7b9a45af580e5450b65f129569d Mon Sep 17 00:00:00 2001 From: Tehom Date: Thu, 1 Jun 2017 02:59:46 -0400 Subject: [PATCH 109/133] Prevent AttributeError when self.obj is None. --- evennia/commands/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 "" From 7be82159f4664365dd25679341598d94f3592c44 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Jun 2017 18:41:21 +0200 Subject: [PATCH 110/133] Add LockableThreadPool class. --- evennia/server/server.py | 6 +++--- evennia/server/webserver.py | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/evennia/server/server.py b/evennia/server/server.py index b5a1aff51..c510d2566 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -526,12 +526,12 @@ 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 diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index e974226f7..ccece6390 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.defer import Deferred from twisted.web.wsgi import WSGIResource from django.conf import settings @@ -28,6 +30,25 @@ 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 + + def lock(self): + self._accept_new = False + + def callInThread(self, func, *args, **kwargs): + """ + called in the main reactor thread. + """ + if self._accept_new: + threadpool.ThreadPool(self, func, *args, **kwargs) + + # # X-Forwarded-For Handler # @@ -115,7 +136,7 @@ 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): """ From d73a2876cfa4a9b55f2dcea840d72184d29b3c66 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 4 Jun 2017 00:35:55 +0200 Subject: [PATCH 111/133] Add support for cmdline reloading when requests are blocked. --- evennia/server/server.py | 40 ++++++++++++++++++------------------- evennia/server/webserver.py | 10 ++++++++-- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/evennia/server/server.py b/evennia/server/server.py index 3da09abed..206fc5490 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() @@ -168,10 +168,18 @@ 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 + d = self.web_root.empty_threadpool() + d.addCallback(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 @@ -349,9 +357,6 @@ class Evennia(object): from evennia.server.models import ServerConfig from evennia.utils import gametime as _GAMETIME_MODULE - # lock the threadpool from accepting more requests - self.web_root.pool.lock() - if mode == 'reload': # call restart hooks ServerConfig.objects.conf("server_restart_mode", "reload") @@ -387,24 +392,17 @@ 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 WEBSERVER_ENABLED: + 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. + # kill the server self.shutdown_complete = True - if WEBSERVER_ENABLED: - # Make sure to not continue until threadpool queue is empty. - deferred = self.web_root.get_pending_requests() - deferred.addCallback(lambda _: reactor.stop()) - from twisted.internet import task - yield task.deferLater(reactor, 1, deferred.callback, None) - else: - # kill the server - reactor.callLater(1, reactor.stop) + reactor.callLater(1, reactor.stop) # we make sure the proper gametime is saved as late as possible ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.runtime()) diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index d08f113c1..754c40d6d 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -149,12 +149,13 @@ class DjangoWebRoot(resource.Resource): pool (ThreadPool): The twisted threadpool. """ - self._pool = pool + self.pool = pool + self._echo_log = True self._pending_requests = {} resource.Resource.__init__(self) self.wsgi_resource = WSGIResource(reactor, pool, WSGIHandler()) - def get_pending_requests(self): + def empty_threadpool(self): """ Converts our _pending_requests list of deferreds into a DeferredList @@ -162,6 +163,11 @@ class DjangoWebRoot(resource.Resource): 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): From ccfbe06ded3a2113bcd30b9da420ee765a5cd89b Mon Sep 17 00:00:00 2001 From: Tehom Date: Sat, 3 Jun 2017 21:57:11 -0400 Subject: [PATCH 112/133] Fix AttributeError when webserver isn't enabled. --- evennia/server/server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/evennia/server/server.py b/evennia/server/server.py index 206fc5490..e1d120f4d 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -174,8 +174,11 @@ class Evennia(object): # (see https://github.com/evennia/evennia/issues/1128) def _wrap_sigint_handler(*args): from twisted.internet.defer import Deferred - d = self.web_root.empty_threadpool() - d.addCallback(lambda _: self.shutdown(_reactor_stopping=True)) + if WEBSERVER_ENABLED: + 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 From b269ef265ec4cfba62f0072b2d234cda603e8681 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 4 Jun 2017 11:25:17 +0200 Subject: [PATCH 113/133] Add IAC+GA for telnet messaging, as per #1330. --- evennia/server/portal/telnet.py | 2 +- evennia/server/portal/telnet_oob.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index a579e001e..dbccf5e5b 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -221,7 +221,7 @@ 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') - return self.transport.write(mccp_compress(self, line)) + return self.transport.write(mccp_compress(self, line + IAC + GA)) # Session hooks 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 From 167d09b4cdf55d54e7cec12937f1157c5653dec8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 4 Jun 2017 22:55:28 +0200 Subject: [PATCH 114/133] Add secret_settings and mechanisms for managing it. This allows for easier hiding game- and server-specific settings when sharing the game dir with others. --- .../game_template/{.gitignore => gitignore} | 4 +-- .../server/conf/secret_settings.py | 17 +++++++++++ evennia/game_template/server/conf/settings.py | 19 ++++++------ evennia/server/evennia_launcher.py | 30 ++++++++++++------- evennia/server/server.py | 4 +-- 5 files changed, 50 insertions(+), 24 deletions(-) rename evennia/game_template/{.gitignore => gitignore} (89%) create mode 100644 evennia/game_template/server/conf/secret_settings.py 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/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 96db22fd1..09ca4a3c9 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -506,7 +506,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 +514,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 +544,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 +567,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/server.py b/evennia/server/server.py index e1d120f4d..7912c6a1b 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -174,7 +174,7 @@ class Evennia(object): # (see https://github.com/evennia/evennia/issues/1128) def _wrap_sigint_handler(*args): from twisted.internet.defer import Deferred - if WEBSERVER_ENABLED: + if hasattr(self, "webroot"): d = self.web_root.empty_threadpool() d.addCallback(lambda _: self.shutdown(_reactor_stopping=True)) else: @@ -399,7 +399,7 @@ class Evennia(object): # for Windows we need to remove pid files manually os.remove(SERVER_PIDFILE) - if WEBSERVER_ENABLED: + if hasattr(self, "web_root"): # not set very first start yield self.web_root.empty_threadpool() if not _reactor_stopping: From 90bd1a77ddbab2a30a3953869ccc28453fc1772e Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 4 Jun 2017 23:21:24 +0200 Subject: [PATCH 115/133] Fix typo webroot->web_root --- evennia/server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/server.py b/evennia/server/server.py index 7912c6a1b..038846e5b 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -174,7 +174,7 @@ class Evennia(object): # (see https://github.com/evennia/evennia/issues/1128) def _wrap_sigint_handler(*args): from twisted.internet.defer import Deferred - if hasattr(self, "webroot"): + if hasattr(self, "web_root"): d = self.web_root.empty_threadpool() d.addCallback(lambda _: self.shutdown(_reactor_stopping=True)) else: From 5808377085c37b32c95279c607dd9ffce11c2316 Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sun, 4 Jun 2017 18:46:31 -0700 Subject: [PATCH 116/133] Change IAC + GA to key off of MUDPROMPT @option Several clients interpret IAC + GA as a linefeed, in order to support MUDs that always end a line with them. This causes Evennia to spew empty lines everywhere if IAC + GA is always on. --- evennia/server/inputfuncs.py | 3 ++- evennia/server/portal/telnet.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/evennia/server/inputfuncs.py b/evennia/server/inputfuncs.py index 17442ba6e..509d84be4 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", + "MUDPROMPT")) session.msg(client_options=options) return diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index dbccf5e5b..e9f43443d 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -221,7 +221,9 @@ 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') - return self.transport.write(mccp_compress(self, line + IAC + GA)) + if self.protocol_flags.get("MUDPROMPT", False): + line = line + IAC + GA + return self.transport.write(mccp_compress(self, line)) # Session hooks From f0d632cb510a503064b650382602050ce6df8895 Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sun, 4 Jun 2017 19:03:28 -0700 Subject: [PATCH 117/133] Amend previous commit with validator. --- evennia/commands/default/player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/commands/default/player.py b/evennia/commands/default/player.py index 55919970b..4d03fb813 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, + "MUDPROMPT": validate_bool, "MXP": validate_bool, "NOCOLOR": validate_bool, "NOPKEEPALIVE": validate_bool, From a2578500c7439675661acf85fd9711aeaffaf10c Mon Sep 17 00:00:00 2001 From: Tehom Date: Mon, 5 Jun 2017 04:18:55 -0400 Subject: [PATCH 118/133] Fix another AttributeError in errback for #1207. --- evennia/server/webserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index 754c40d6d..ceb072c5c 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -125,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 From 63eec3a659560e1597c5ff09ee831040adffe59a Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 00:03:16 +0200 Subject: [PATCH 119/133] Implement SUPPRESS-GO-AHEAD telnet option With the current setup, the server will negotiate SUPPRESS-GO-AHEAD as follows: Server sends WILL SUPPRESS-GO-AHEAD Client will then send or reply with DO/DONT SUPPRESS-GO-AHEAD Evennia will abide by the instruction of the client, but defaults to suppressing GA messages after every line. --- evennia/commands/default/player.py | 2 +- evennia/server/inputfuncs.py | 4 +++- evennia/server/portal/telnet.py | 17 +++++++++++------ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/evennia/commands/default/player.py b/evennia/commands/default/player.py index 4d03fb813..296618334 100644 --- a/evennia/commands/default/player.py +++ b/evennia/commands/default/player.py @@ -560,7 +560,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): "CLIENTNAME": utils.to_str, "ENCODING": validate_encoding, "MCCP": validate_bool, - "MUDPROMPT": validate_bool, + "NOGOAHEAD": validate_bool, "MXP": validate_bool, "NOCOLOR": validate_bool, "NOPKEEPALIVE": validate_bool, diff --git a/evennia/server/inputfuncs.py b/evennia/server/inputfuncs.py index 509d84be4..c956db087 100644 --- a/evennia/server/inputfuncs.py +++ b/evennia/server/inputfuncs.py @@ -194,7 +194,7 @@ def client_options(session, *args, **kwargs): "MCCP", "SCREENHEIGHT", "SCREENWIDTH", "INPUTDEBUG", "RAW", "NOCOLOR", - "MUDPROMPT")) + "NOGOAHEAD")) session.msg(client_options=options) return @@ -245,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/telnet.py b/evennia/server/portal/telnet.py index e9f43443d..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,8 +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 self.protocol_flags.get("MUDPROMPT", False): - line = line + IAC + GA + if not self.protocol_flags.get("NOGOAHEAD", True): + line += IAC + GA return self.transport.write(mccp_compress(self, line)) # Session hooks From e85153d92678cb36c97e66f99545a69dd7a0c629 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 00:17:48 +0200 Subject: [PATCH 120/133] Add missing suppress_ga file to git. --- evennia/server/portal/suppress_ga.py | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 evennia/server/portal/suppress_ga.py diff --git a/evennia/server/portal/suppress_ga.py b/evennia/server/portal/suppress_ga.py new file mode 100644 index 000000000..a52fe25e5 --- /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 can be activated explicitly with the ADDGOAHEAD 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() From 04e6fbbb091b5e58cb9c11ce24cc366553bf4e52 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 00:20:49 +0200 Subject: [PATCH 121/133] Fix reference in docstring. --- evennia/server/portal/suppress_ga.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/portal/suppress_ga.py b/evennia/server/portal/suppress_ga.py index a52fe25e5..6326a424f 100644 --- a/evennia/server/portal/suppress_ga.py +++ b/evennia/server/portal/suppress_ga.py @@ -8,7 +8,7 @@ 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 can be activated explicitly with the ADDGOAHEAD option. +It is set as the NOGOAHEAD protocol_flag option. http://www.faqs.org/rfcs/rfc858.html From 7e416e0cd6e34c77693d68c3e95129f512059099 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 14:56:13 +0200 Subject: [PATCH 122/133] Start fixing things for django 1.10. --- evennia/server/server.py | 2 +- evennia/typeclasses/models.py | 4 +++- requirements.txt | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/evennia/server/server.py b/evennia/server/server.py index 038846e5b..70d07bbea 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -195,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')): diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 3cb4af35d..98aaa0e57 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -98,7 +98,9 @@ class TypeclassBase(SharedMemoryModelBase): # 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 = patched_new(cls, name, bases, attrs) + new_class = super(TypeclassBase, cls).__new__(cls, name, bases, attrs) + #new_class = patched_new(cls, name, bases, attrs) # attach signals signals.post_save.connect(post_save, sender=new_class) diff --git a/requirements.txt b/requirements.txt index d1e4790d4..9c7f10fba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # Evennia dependencies, for Linux/Mac platforms -django >= 1.8, < 1.10 +django >= 1.9, < 1.11 twisted >= 16.0.0 mock >= 1.0.1 pillow == 2.9.0 From a727547b6fafad721d3f4bc07a8c4bb45388ff92 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 15:44:53 +0200 Subject: [PATCH 123/133] Test typeclass upgrade for new django version. Not working yet. --- evennia/typeclasses/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 98aaa0e57..dc7060da8 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 @@ -99,8 +100,9 @@ class TypeclassBase(SharedMemoryModelBase): # with a few lines changed as per # https://code.djangoproject.com/ticket/11560 #new_class = patched_new(cls, name, bases, attrs) - new_class = super(TypeclassBase, cls).__new__(cls, name, bases, attrs) + #new_class = super(TypeclassBase, cls).__new__(cls, name, bases, attrs) #new_class = patched_new(cls, name, bases, attrs) + new_class = super(ModelBase, cls).__new__(cls, name, bases, attrs) # attach signals signals.post_save.connect(post_save, sender=new_class) From cdeffc1f0841ee0b86f6cadaebcce5be5200f927 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 17:31:22 +0200 Subject: [PATCH 124/133] Add some debug outputs. --- evennia/typeclasses/models.py | 1 + evennia/typeclasses/tags.py | 1 + 2 files changed, 2 insertions(+) diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index dc7060da8..f827169b9 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -103,6 +103,7 @@ class TypeclassBase(SharedMemoryModelBase): #new_class = super(TypeclassBase, cls).__new__(cls, name, bases, attrs) #new_class = patched_new(cls, name, bases, attrs) new_class = super(ModelBase, cls).__new__(cls, name, bases, attrs) + print "name:", name, new_class._meta.proxy # attach signals signals.post_save.connect(post_save, sender=new_class) diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index e9bd24eed..dea061608 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -107,6 +107,7 @@ class TagHandler(object): query = {"%s__id" % self._model : self._objid, "tag__db_model" : self._model, "tag__db_tagtype" : self._tagtype} + print("CACHE:", query, self._m2m_fieldname) tags = [conn.tag for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)] self._cache = dict(("%s-%s" % (to_str(tag.db_key).lower(), tag.db_category.lower() if tag.db_category else None), From ce3558d654dd041b5ae51d440399a294dae7cb2f Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 18:45:41 +0200 Subject: [PATCH 125/133] Go to Django 1.10. Remove support for Django 1.9. --- evennia/typeclasses/django_new_patch.py | 240 ------------------------ evennia/typeclasses/models.py | 16 +- evennia/typeclasses/tags.py | 1 - evennia/utils/idmapper/models.py | 2 +- requirements.txt | 2 +- 5 files changed, 6 insertions(+), 255 deletions(-) delete mode 100644 evennia/typeclasses/django_new_patch.py 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 f827169b9..4fbbe891d 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -95,15 +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 = super(TypeclassBase, cls).__new__(cls, name, bases, attrs) - #new_class = patched_new(cls, name, bases, attrs) - new_class = super(ModelBase, cls).__new__(cls, name, bases, attrs) - print "name:", name, new_class._meta.proxy + new_class = ModelBase.__new__(cls, name, bases, attrs) # attach signals signals.post_save.connect(post_save, sender=new_class) @@ -207,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: @@ -219,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/typeclasses/tags.py b/evennia/typeclasses/tags.py index dea061608..e9bd24eed 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -107,7 +107,6 @@ class TagHandler(object): query = {"%s__id" % self._model : self._objid, "tag__db_model" : self._model, "tag__db_tagtype" : self._tagtype} - print("CACHE:", query, self._m2m_fieldname) tags = [conn.tag for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)] self._cache = dict(("%s-%s" % (to_str(tag.db_key).lower(), tag.db_category.lower() if tag.db_category else None), diff --git a/evennia/utils/idmapper/models.py b/evennia/utils/idmapper/models.py index 54013e3dd..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 diff --git a/requirements.txt b/requirements.txt index 9c7f10fba..3f507cde7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # Evennia dependencies, for Linux/Mac platforms -django >= 1.9, < 1.11 +django > 1.9, < 1.11 twisted >= 16.0.0 mock >= 1.0.1 pillow == 2.9.0 From e2de340f7d658346c4c4ceb30473b6fc0fbea522 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 18:56:04 +0200 Subject: [PATCH 126/133] Move to Django 1.11. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3f507cde7..bbb9e60db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # Evennia dependencies, for Linux/Mac platforms -django > 1.9, < 1.11 +django > 1.10, < 2.0 twisted >= 16.0.0 mock >= 1.0.1 pillow == 2.9.0 From c60555b70a833c79290d747fbb7149323e85a432 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 19:34:56 +0200 Subject: [PATCH 127/133] Add migrations to django 1.11. --- .../migrations/0011_auto_20170606_1731.py | 81 +++++++++++++++++++ evennia/comms/models.py | 22 ++--- .../migrations/0002_auto_20170606_1731.py | 20 +++++ evennia/help/models.py | 2 +- .../migrations/0006_auto_20170606_1731.py | 32 ++++++++ evennia/objects/models.py | 4 +- .../migrations/0006_auto_20170606_1731.py | 31 +++++++ .../migrations/0008_auto_20170606_1731.py | 25 ++++++ evennia/typeclasses/models.py | 4 +- 9 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 evennia/comms/migrations/0011_auto_20170606_1731.py create mode 100644 evennia/help/migrations/0002_auto_20170606_1731.py create mode 100644 evennia/objects/migrations/0006_auto_20170606_1731.py create mode 100644 evennia/players/migrations/0006_auto_20170606_1731.py create mode 100644 evennia/scripts/migrations/0008_auto_20170606_1731.py 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 c7b5e8d7b..5465e51c6 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -79,9 +79,9 @@ 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_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).") @@ -89,11 +89,11 @@ 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_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. @@ -107,11 +107,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 @@ -580,10 +580,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/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/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/typeclasses/models.py b/evennia/typeclasses/models.py index 4fbbe891d..c163a95e4 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -172,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 From f6ad6213d733fb29f5a477d08e34eca692967262 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 6 Jun 2017 19:43:28 +0200 Subject: [PATCH 128/133] Update launcher django requirement message. --- evennia/server/evennia_launcher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 09ca4a3c9..9f9988676 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 From 8b150ce4194925410881b98a0467d954991f2657 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 12 Jun 2017 13:32:08 -0700 Subject: [PATCH 129/133] [Event system] Check that the script has valid non-attributes before using it --- evennia/contrib/events/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/contrib/events/utils.py b/evennia/contrib/events/utils.py index 9ecb3544e..4380f92b7 100644 --- a/evennia/contrib/events/utils.py +++ b/evennia/contrib/events/utils.py @@ -57,6 +57,7 @@ def register_events(path_or_typeclass): 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 From d894b9571730ea8d42efe467d7859818a3d438d6 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 14 Jun 2017 12:35:23 -0700 Subject: [PATCH 130/133] Add cmdhandler's ability to handle 'yield' in command.func() --- evennia/commands/cmdhandler.py | 62 +++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 328168737..0b9b8d94a 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -38,6 +38,7 @@ from weakref import WeakValueDictionary from traceback import format_exc from itertools import chain from copy import copy +import types from twisted.internet.defer import inlineCallbacks, returnValue from django.conf import settings from evennia.comms.channelhandler import CHANNELHANDLER @@ -506,7 +507,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() @@ -668,3 +675,56 @@ 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) + +def _progressive_cmd_run(cmd, generator, response=None): + """ + Progressively call the command that was given in argument. + + Args: + cmd (Command): the command itself. + generator (GeneratorType): the generator describing the processing. + reponse (str, optional): the response to send to the generator. + + 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. + + """ + 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): + from evennia.utils.evmenu import get_input + 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. + + 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: + Always False (stop processing). + + """ + # We call it in a 'utils.delay()' to make sure the input is properly closed. + utils.delay(0, _progressive_cmd_run, cmd, generator, response=result) + + return False From 5fa084c976442befd129843cf79bf82cef10933b Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 14 Jun 2017 22:02:01 +0200 Subject: [PATCH 131/133] Refactor yield-mechanism to be present on top of the module, removing dependence on utils.delay and optimizing some imports. --- evennia/commands/cmdhandler.py | 125 +++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 52 deletions(-) diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 0b9b8d94a..c0ff71e76 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -39,6 +39,8 @@ 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 @@ -128,6 +130,12 @@ _ERROR_RECURSION_LIMIT = "Command recursion limit ({recursion_limit}) " \ "reached for '{raw_string}' ({cmdclass})." +# delayed imports +_GET_INPUT = None + + +# helper functions + def _msg_err(receiver, stringtuple): """ Helper function for returning an error to the caller. @@ -152,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): @@ -676,55 +749,3 @@ def cmdhandler(called_by, raw_string, _testing=False, callertype="session", sess # This catches exceptions in cmdhandler exceptions themselves _msg_err(error_to, _ERROR_CMDHANDLER) -def _progressive_cmd_run(cmd, generator, response=None): - """ - Progressively call the command that was given in argument. - - Args: - cmd (Command): the command itself. - generator (GeneratorType): the generator describing the processing. - reponse (str, optional): the response to send to the generator. - - 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. - - """ - 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): - from evennia.utils.evmenu import get_input - 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. - - 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: - Always False (stop processing). - - """ - # We call it in a 'utils.delay()' to make sure the input is properly closed. - utils.delay(0, _progressive_cmd_run, cmd, generator, response=result) - - return False From 35db4bf30184e2ce8e75fb892430ff5ced198630 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 14 Jun 2017 22:07:15 +0200 Subject: [PATCH 132/133] Make minor docstring indent fix. --- evennia/commands/cmdhandler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index c0ff71e76..a24f46d44 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -143,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})." From 1ddddef23b38c3028544768805c2a55ecebcef29 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 15 Jun 2017 22:21:21 +0200 Subject: [PATCH 133/133] Add better instructions for upgrading django. --- evennia/server/evennia_launcher.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 9f9988676..28f6c60bd 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -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 = \