From d6c9d28d4f39218c2b480be49df6db28724399c8 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Thu, 16 Mar 2017 17:47:24 -0700 Subject: [PATCH] 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 44453f3060..4088477775 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 e5d86aafc9..eb47709ea2 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 b763d5781c..5d4badde7a 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 3cc18890db..e653c4d309 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)