""" This module defines Scripts, out-of-character entities that can store data both on themselves and on other objects while also having the ability to run timers. """ from django.utils.translation import gettext as _ from twisted.internet.defer import Deferred, maybeDeferred from twisted.internet.task import LoopingCall from evennia.scripts.manager import ScriptManager from evennia.scripts.models import ScriptDB from evennia.typeclasses.models import TypeclassBase from evennia.utils import create, logger __all__ = ["DefaultScript", "DoNothing", "Store"] class ExtendedLoopingCall(LoopingCall): """ Custom child of LoopingCall that can start at a delay different than `self.interval` and self.count=0. This allows it to support pausing by resuming at a later period. """ start_delay = None callcount = 0 def start(self, interval, now=True, start_delay=None, count_start=0): """ Start running function every interval seconds. This overloads the LoopingCall default by offering the start_delay keyword and ability to repeat. Args: interval (int): Repeat interval in seconds. now (bool, optional): Whether to start immediately or after `start_delay` seconds. start_delay (int, optional): This only applies is `now=False`. It gives number of seconds to wait before starting. If `None`, use `interval` as this value instead. Internally, this is used as a way to start with a variable start time after a pause. count_start (int): Number of repeats to start at. The count goes up every time the system repeats. This is used to implement something repeating `N` number of times etc. Raises: AssertError: if trying to start a task which is already running. ValueError: If interval is set to an invalid value < 0. Notes: As opposed to Twisted's inbuilt count mechanism, this system will count also if force_repeat() was called rather than just the number of `interval` seconds since the start. This allows us to force-step through a limited number of steps if we want. """ assert not self.running, "Tried to start an already running ExtendedLoopingCall." if interval < 0: raise ValueError("interval must be >= 0") self.running = True deferred = self._deferred = Deferred() self.starttime = self.clock.seconds() self.interval = interval self._runAtStart = now self.callcount = max(0, count_start) self.start_delay = start_delay if start_delay is None else max(0, start_delay) if now: # run immediately self() elif start_delay is not None and start_delay >= 0: # start after some time: for this to work we need to # trick _scheduleFrom by temporarily setting a different # self.interval for it to check. real_interval, self.interval = self.interval, start_delay self._scheduleFrom(self.starttime) # re-set the actual interval (this will be picked up # next time it runs self.interval = real_interval else: self._scheduleFrom(self.starttime) return deferred def __call__(self): """ Tick one step. We update callcount (tracks number of calls) as well as null start_delay (needed in order to correctly estimate next_call_time at all times). """ self.callcount += 1 if self.start_delay: self.start_delay = None self.starttime = self.clock.seconds() if self._deferred: LoopingCall.__call__(self) def force_repeat(self): """ Force-fire the callback Raises: AssertionError: When trying to force a task that is not running. """ assert self.running, "Tried to fire an ExtendedLoopingCall that was not running." self.call.cancel() self.call = None self.starttime = self.clock.seconds() self() def next_call_time(self): """ Get the next call time. This also takes the eventual effect of start_delay into account. Returns: int or None: The time in seconds until the next call. This takes `start_delay` into account. Returns `None` if the task is not running. """ if self.running and self.interval > 0: total_runtime = self.clock.seconds() - self.starttime interval = self.start_delay or self.interval return max(0, interval - (total_runtime % self.interval)) class ScriptBase(ScriptDB, metaclass=TypeclassBase): """ Base class for scripts. Don't inherit from this, inherit from the class `DefaultScript` below instead. This handles the timer-component of the Script. """ objects = ScriptManager() def __str__(self): return "<{cls} {key}>".format(cls=self.__class__.__name__, key=self.key) def __repr__(self): return str(self) def at_idmapper_flush(self): """ If we're flushing this object, make sure the LoopingCall is gone too. """ ret = super().at_idmapper_flush() if ret and self.ndb._task: self.ndb._pause_task(auto_pause=True) # TODO - restart anew ? return ret def _start_task( self, interval=None, start_delay=None, repeats=None, force_restart=False, auto_unpause=False, **kwargs, ): """ Start/Unpause task runner, optionally with new values. If given, this will update the Script's fields. Keyword Args: interval (int): How often to tick the task, in seconds. If this is <= 0, no task will start and properties will not be updated on the Script. start_delay (int): If the start should be delayed. repeats (int): How many repeats. 0 for infinite repeats. force_restart (bool): If set, always create a new task running even if an old one already was running. Otherwise this will only happen if new script properties were passed. auto_unpause (bool): This is an automatic unpaused (used e.g by Evennia after a reload) and should not un-pause manually paused Script timers. Note: If setting the `start-delay` of a *paused* Script, the Script will restart exactly after that new start-delay, ignoring the time it was paused at. If only changing the `interval`, the Script will come out of pause comparing the time it spent in the *old* interval with the *new* interval in order to determine when next to fire. Examples: - Script previously had an interval of 10s and was paused 5s into that interval. Script is now restarted with a 20s interval. It will next fire after 15s. - Same Script is restarted with a 3s interval. It will fire immediately. """ if self.pk is None: # script object already deleted from db - don't start a new timer raise ScriptDB.DoesNotExist # handle setting/updating fields update_fields = [] old_interval = self.db_interval if interval is not None: self.db_interval = interval update_fields.append("db_interval") if start_delay is not None: self.db_start_delay = start_delay update_fields.append("db_start_delay") if repeats is not None: self.db_repeats = repeats update_fields.append("db_repeats") # validate interval if self.db_interval and self.db_interval > 0: if not self.is_active: self.db_is_active = True update_fields.append("db_is_active") else: # no point in starting a task with no interval. return restart = bool(update_fields) or force_restart self.save(update_fields=update_fields) if self.ndb._task and self.ndb._task.running: if restart: # a change needed/forced; stop/remove old task self._stop_task() else: # task alreaady running and no changes needed return if not self.ndb._task: # we should have a fresh task after this point self.ndb._task = ExtendedLoopingCall(self._step_task) self._unpause_task( interval=interval, start_delay=start_delay, auto_unpause=auto_unpause, old_interval=old_interval, ) if not self.ndb._task.running: # if not unpausing started it, start script anew with the new values self.ndb._task.start(self.db_interval, now=not self.db_start_delay) self.at_start(**kwargs) def _pause_task(self, auto_pause=False, **kwargs): """ Pause task where it is, saving the current status. Args: auto_pause (str): """ if not self.db._paused_time: # only allow pause if not already paused task = self.ndb._task if task: self.db._paused_time = task.next_call_time() self.db._paused_callcount = task.callcount self.db._manually_paused = not auto_pause if task.running: task.stop() self.ndb._task = None self.at_pause(auto_pause=auto_pause, **kwargs) def _unpause_task( self, interval=None, start_delay=None, auto_unpause=False, old_interval=0, **kwargs ): """ Unpause task from paused status. This is used for auto-paused tasks, such as tasks paused on a server reload. Args: interval (int): How often to tick the task, in seconds. start_delay (int): If the start should be delayed. auto_unpause (bool): If set, this will only unpause scripts that were unpaused automatically (useful during a system reload/shutdown). old_interval (int): The old Script interval (or current one if nothing changed). Used to recalculate the unpause startup interval. """ paused_time = self.db._paused_time if paused_time: if auto_unpause and self.db._manually_paused: # this was manually paused. return # task was paused. This will use the new values as needed. callcount = self.db._paused_callcount or 0 if start_delay is None and interval is not None: # adjust start-delay based on how far we were into previous interval start_delay = max(0, interval - (old_interval - paused_time)) else: start_delay = paused_time if not self.ndb._task: self.ndb._task = ExtendedLoopingCall(self._step_task) self.ndb._task.start( self.db_interval, now=False, start_delay=start_delay, count_start=callcount ) self.db._paused_time = None self.db._paused_callcount = None self.db._manually_paused = None self.at_start(**kwargs) def _stop_task(self, **kwargs): """ Stop task runner and delete the task. """ task_stopped = False task = self.ndb._task if task and task.running: task.stop() task_stopped = True self.ndb._task = None self.db_is_active = False # make sure this is not confused as a paused script self.db._paused_time = None self.db._paused_callcount = None self.db._manually_paused = None self.save(update_fields=["db_is_active"]) if task_stopped: self.at_stop(**kwargs) def _step_errback(self, e): """ Callback for runner errors """ cname = self.__class__.__name__ estring = _( "Script {key}(#{dbid}) of type '{name}': at_repeat() error '{err}'.".format( key=self.key, dbid=self.dbid, name=cname, err=e.getErrorMessage() ) ) try: self.db_obj.msg(estring) except Exception: # we must not crash inside the errback, even if db_obj is None. pass logger.log_err(estring) def _step_callback(self): """ Step task runner. No try..except needed due to defer wrap. """ if not self.ndb._task: # if there is no task, we have no business using this method return if not self.is_valid(): self.stop() return # call hook try: self.at_repeat() except Exception: logger.log_trace() raise # check repeats if self.ndb._task: # we need to check for the task in case stop() was called # inside at_repeat() and it already went away. callcount = self.ndb._task.callcount maxcount = self.db_repeats if maxcount > 0 and maxcount <= callcount: self.stop() def _step_task(self): """ Step task. This groups error handling. """ try: return maybeDeferred(self._step_callback).addErrback(self._step_errback) except Exception: logger.log_trace() return None # Access methods / hooks def at_first_save(self, **kwargs): """ This is called after very first time this object is saved. Generally, you don't need to overload this, but only the hooks called by this method. Args: **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). """ self.basetype_setup() self.at_script_creation() # initialize Attribute/TagProperties self.init_evennia_properties() if hasattr(self, "_createdict"): # this will only be set if the utils.create_script # function was used to create the object. We want # the create call's kwargs to override the values # set by hooks. cdict = self._createdict updates = [] if not cdict.get("key"): if not self.db_key: self.db_key = "#%i" % self.dbid updates.append("db_key") elif self.db_key != cdict["key"]: self.db_key = cdict["key"] updates.append("db_key") if cdict.get("interval") and self.interval != cdict["interval"]: self.db_interval = max(0, cdict["interval"]) updates.append("db_interval") if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]: self.db_start_delay = cdict["start_delay"] updates.append("db_start_delay") if cdict.get("repeats") and self.repeats != cdict["repeats"]: self.db_repeats = max(0, cdict["repeats"]) updates.append("db_repeats") if cdict.get("persistent") and self.persistent != cdict["persistent"]: self.db_persistent = cdict["persistent"] updates.append("db_persistent") if cdict.get("desc") and self.desc != cdict["desc"]: self.db_desc = cdict["desc"] updates.append("db_desc") if updates: self.save(update_fields=updates) if cdict.get("permissions"): self.permissions.batch_add(*cdict["permissions"]) if cdict.get("locks"): self.locks.add(cdict["locks"]) if cdict.get("tags"): # this should be a list of tags, tuples (key, category) or (key, category, data) self.tags.batch_add(*cdict["tags"]) if cdict.get("attributes"): # this should be tuples (key, val, ...) self.attributes.batch_add(*cdict["attributes"]) if cdict.get("nattributes"): # this should be a dict of nattrname:value for key, value in cdict["nattributes"]: self.nattributes.add(key, value) if cdict.get("autostart"): # autostart the script self._start_task(force_restart=True) def delete(self): """ Delete the Script. Normally stops any timer task. This fires at_script_delete before deletion. Returns: bool: If deletion was successful or not. Only time this can fail would be if the script was already previously deleted, or `at_script_delete` returns False. """ if not self.pk or not self.at_script_delete(): return False self._stop_task() super().delete() return True def basetype_setup(self): """ Changes fundamental aspects of the type. Usually changes are made in at_script creation instead. """ pass def at_init(self): """ Called when the Script is cached in the idmapper. This is usually more reliable than overriding `__init__` since the latter can be called at unexpected times. """ pass def at_script_creation(self): """ Should be overridden in child. """ pass def at_script_delete(self): """ Called when script is deleted, before the script timer stops. Returns: bool: If False, deletion is aborted. """ return True def is_valid(self): """ If returning False, `at_repeat` will not be called and timer will stop updating. """ return True def at_repeat(self, **kwargs): """ Called repeatedly every `interval` seconds, once `.start()` has been called on the Script at least once. Args: **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). """ pass def at_start(self, **kwargs): pass def at_pause(self, **kwargs): pass def at_stop(self, **kwargs): pass def start(self, interval=None, start_delay=None, repeats=None, **kwargs): """ Start/Unpause timer component, optionally with new values. If given, this will update the Script's fields. This will start `at_repeat` being called every `interval` seconds. Keyword Args: interval (int): How often to fire `at_repeat` in seconds. start_delay (int): If the start of ticking should be delayed. repeats (int): How many repeats. 0 for infinite repeats. **kwargs: Optional (default unused) kwargs passed on into the `at_start` hook. Notes: If setting the `start-delay` of a *paused* Script, the Script will restart exactly after that new start-delay, ignoring the time it was paused at. If only changing the `interval`, the Script will come out of pause comparing the time it spent in the *old* interval with the *new* interval in order to determine when next to fire. Examples: - Script previously had an interval of 10s and was paused 5s into that interval. Script is now restarted with a 20s interval. It will next fire after 15s. - Same Script is restarted with a 3s interval. It will fire immediately. """ self._start_task(interval=interval, start_delay=start_delay, repeats=repeats, **kwargs) # legacy alias update = start def stop(self, **kwargs): """ Stop the Script's timer component. This will not delete the Sctipt, just stop the regular firing of `at_repeat`. Running `.start()` will start the timer anew, optionally with new settings.. Args: **kwargs: Optional (default unused) kwargs passed on into the `at_stop` hook. """ self._stop_task(**kwargs) def pause(self, **kwargs): """ Manually the Script's timer component manually. Args: **kwargs: Optional (default unused) kwargs passed on into the `at_pause` hook. """ self._pause_task(manual_pause=True, **kwargs) def unpause(self, **kwargs): """ Manually unpause a Paused Script. Args: **kwargs: Optional (default unused) kwargs passed on into the `at_start` hook. """ self._unpause_task(**kwargs) def time_until_next_repeat(self): """ Get time until the script fires it `at_repeat` hook again. Returns: int or None: Time in seconds until the script runs again. If not a timed script, return `None`. Notes: This hook is not used in any way by the script's stepping system; it's only here for the user to be able to check in on their scripts and when they will next be run. """ task = self.ndb._task if task: try: return int(round(task.next_call_time())) except TypeError: pass return None def remaining_repeats(self): """ Get the number of returning repeats for limited Scripts. Returns: int or None: The number of repeats remaining until the Script stops. Returns `None` if it has unlimited repeats. """ task = self.ndb._task if task: return max(0, self.db_repeats - task.callcount) return None def reset_callcount(self, value=0): """ Reset the count of the number of calls done. Args: value (int, optional): The repeat value to reset to. Default is to set it all the way back to 0. Notes: This is only useful if repeats != 0. """ task = self.ndb._task if task: task.callcount = max(0, int(value)) def force_repeat(self): """ Fire a premature triggering of the script callback. This will reset the timer and count down repeats as if the script had fired normally. """ task = self.ndb._task if task: task.force_repeat() class DefaultScript(ScriptBase): """ This is the base TypeClass for all Scripts. Scripts describe events, timers and states in game, they can have a time component or describe a state that changes under certain conditions. """ @classmethod def create(cls, key, **kwargs): """ Provides a passthrough interface to the utils.create_script() function. Args: key (str): Name of the new object. Returns: object (Object): A newly created object of the given typeclass. errors (list): A list of errors in string form, if any. """ errors = [] obj = None kwargs["key"] = key # If no typeclass supplied, use this class kwargs["typeclass"] = kwargs.pop("typeclass", cls) try: obj = create.create_script(**kwargs) except Exception: logger.log_trace() errors.append("The script '%s' encountered errors and could not be created." % key) return obj, errors def at_script_creation(self): """ Only called once, when script is first created. """ pass def is_valid(self): """ Is called to check if the script's timer is valid to run at this time. Should return a boolean. If False, the timer will be stopped. """ return True def at_start(self, **kwargs): """ Called whenever the script timer is started, which for persistent timed scripts is at least once every server start. It will also be called when starting again after a pause (including after a server reload). Args: **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). """ pass def at_repeat(self, **kwargs): """ Called repeatedly if this Script is set to repeat regularly. Args: **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). """ pass def at_pause(self, manual_pause=True, **kwargs): """ Called when this script's timer pauses. Args: manual_pause (bool): If set, pausing was done by a direct call. The non-manual pause indicates the script was paused as part of the server reload. """ pass def at_stop(self, **kwargs): """ Called whenever when it's time for this script's timer to stop (either because is_valid returned False, it ran out of iterations or it was manuallys stopped. Args: **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). """ pass def at_script_delete(self): """ Called when the Script is deleted, before stopping the timer. Returns: bool: If False, the deletion is aborted. """ return True def at_server_reload(self): """ This hook is called whenever the server is shutting down for restart/reboot. If you want to, for example, save non-persistent properties across a restart, this is the place to do it. """ pass def at_server_shutdown(self): """ This hook is called whenever the server is shutting down fully (i.e. not for a restart). """ pass def at_server_start(self): """ This hook is called after the server has started. It can be used to add post-startup setup for Scripts without a timer component (for which at_start could be used). """ pass # Some useful default Script types used by Evennia. class DoNothing(DefaultScript): """ A script that does nothing. Used as default fallback. """ def at_script_creation(self): """ Setup the script """ self.key = "sys_do_nothing" self.desc = "This is an empty placeholder script." class Store(DefaultScript): """ Simple storage script """ def at_script_creation(self): """ Setup the script """ self.key = "sys_storage" self.desc = "This is a generic storage container."