593 lines
20 KiB
Python
593 lines
20 KiB
Python
"""
|
|
Scripts for the event system.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from Queue import Queue
|
|
import re
|
|
import sys
|
|
import traceback
|
|
|
|
from django.conf import settings
|
|
from evennia import DefaultObject, DefaultScript, ChannelDB, ScriptDB
|
|
from evennia import logger
|
|
from evennia.utils.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
|
|
|
|
# Constants
|
|
RE_LINE_ERROR = re.compile(r'^ File "\<string\>", line (\d+)')
|
|
|
|
class EventHandler(DefaultScript):
|
|
|
|
"""
|
|
The event handler that contains all events in a global script.
|
|
|
|
This script shouldn't be created more than once. It contains
|
|
event 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):
|
|
"""Hook called when the script is created."""
|
|
self.key = "event_handler"
|
|
self.desc = "Global event handler"
|
|
self.persistent = True
|
|
|
|
# Permanent data to be stored
|
|
self.db.events = {}
|
|
self.db.to_valid = []
|
|
self.db.locked = []
|
|
|
|
# Tasks
|
|
self.db.task_id = 0
|
|
self.db.tasks = {}
|
|
|
|
def at_start(self):
|
|
"""Set up the event system when starting.
|
|
|
|
Note that this hook is called every time the server restarts
|
|
(including when it's reloaded). This hook performs the following
|
|
tasks:
|
|
|
|
- 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()
|
|
|
|
# Generate locals
|
|
self.ndb.current_locals = {}
|
|
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))
|
|
|
|
# Restart the delayed tasks
|
|
now = datetime.now()
|
|
for task_id, definition in tuple(self.db.tasks.items()):
|
|
future, obj, event_name, locals = definition
|
|
seconds = (future - now).total_seconds()
|
|
if seconds < 0:
|
|
seconds = 0
|
|
|
|
delay(seconds, complete_task, task_id)
|
|
|
|
# Place the script in the EventsHandler
|
|
Handler.script = self
|
|
DefaultObject.events = typeclasses.EventObject.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.
|
|
|
|
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.
|
|
|
|
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
|
|
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():
|
|
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__:
|
|
classes.put(parent)
|
|
|
|
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=""):
|
|
"""
|
|
Add the specified event.
|
|
|
|
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?
|
|
parameters (str, optional): optional parameters.
|
|
|
|
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,
|
|
"parameters": parameters,
|
|
})
|
|
|
|
# If not valid, set it in 'to_valid'
|
|
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]
|
|
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
|
|
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 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?
|
|
|
|
Raises:
|
|
RuntimeError if the event is locked.
|
|
|
|
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]
|
|
|
|
# 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(),
|
|
"updated_by": author,
|
|
"valid": valid,
|
|
"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))
|
|
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.
|
|
|
|
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.
|
|
|
|
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.")
|
|
|
|
# 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
|
|
|
|
# Update locked event
|
|
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:
|
|
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()):
|
|
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):
|
|
"""
|
|
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, **kwargs):
|
|
"""
|
|
Call the event.
|
|
|
|
Args:
|
|
obj (Object): the Evennia typeclassed object.
|
|
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.
|
|
|
|
"""
|
|
# First, look for the event type corresponding to this name
|
|
number = kwargs.get("number")
|
|
parameters = kwargs.get("parameters")
|
|
locals = kwargs.get("locals")
|
|
|
|
# Errors should not pass silently
|
|
allowed = ("number", "parameters", "locals")
|
|
if any(k for k in kwargs if k not in allowed):
|
|
raise TypeError("Unknown keyword arguments were specified " \
|
|
"to call events: {}".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)))
|
|
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_trace("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.get_events(obj).get(event_name, [])
|
|
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
|
|
self.ndb.current_locals = locals
|
|
for i, event in enumerate(events):
|
|
if not event["valid"]:
|
|
continue
|
|
|
|
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"]
|
|
oid = obj.id
|
|
logger.log_err("An error occurred during the event {} of " \
|
|
"{} (#{}), number {}\n{}".format(event_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 "<string>", 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,
|
|
oid, number + 1, lineno, line, repr(evalue)))
|
|
|
|
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:
|
|
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):
|
|
|
|
"""Gametime-sensitive script."""
|
|
|
|
def at_script_creation(self):
|
|
"""The script is created."""
|
|
self.start_delay = True
|
|
self.persistent = True
|
|
|
|
# Script attributes
|
|
self.db.time_format = None
|
|
self.db.event_name = "time"
|
|
self.db.number = None
|
|
|
|
def at_repeat(self):
|
|
"""
|
|
Call the event and reset interval.
|
|
|
|
It is necessary to restart the script to reset its interval
|
|
only twice after a reload. When the script has undergone
|
|
down time, there's usually a slight shift in game time. Once
|
|
the script restarts once, it will set the average time it
|
|
needs for all its future intervals and should not need to be
|
|
restarted. In short, a script that is created shouldn't need
|
|
to restart more than once, and a script that is reloaded should
|
|
restart only twice.
|
|
|
|
"""
|
|
if self.db.time_format:
|
|
# If the 'usual' time is set, use it
|
|
seconds = self.ndb.usual
|
|
if seconds is None:
|
|
seconds, usual, details = get_next_wait(self.db.time_format)
|
|
self.ndb.usual = usual
|
|
|
|
if self.interval != seconds:
|
|
self.restart(interval=seconds)
|
|
|
|
if self.db.event_name and self.db.number is not None:
|
|
obj = self.obj
|
|
if not obj.events:
|
|
return
|
|
|
|
event_name = self.db.event_name
|
|
number = self.db.number
|
|
obj.events.call(event_name, obj, number=number)
|
|
|
|
|
|
# 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_trace("Can't get the event handler.")
|
|
return
|
|
|
|
if task_id not in script.db.tasks:
|
|
logger.log_err("The task #{} was scheduled, but it cannot be " \
|
|
"found".format(task_id))
|
|
return
|
|
|
|
delta, obj, event_name, locals = script.db.tasks.pop(task_id)
|
|
script.call_event(obj, event_name, locals=locals)
|