diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 1acf45a2f3..167fc83515 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -272,6 +272,12 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): """ + # Determines which order command sets begin to be assembled from. + # Accounts are usually second. + cmd_order = 50 + cmd_order_error = 0 + cmd_type = "account" + objects = AccountManager() # Used by account.create_character() to choose default typeclass for characters. @@ -309,6 +315,20 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): def characters(self): return CharactersHandler(self) + def get_command_objects(self) -> dict[str, "CommandObject"]: + """ + Overrideable method which returns a dictionary of all the kinds of CommandObjects + linked to this Account. + + In all normal cases, that's just the account itself. + + The cmdhandler uses this to determine available cmdsets when executing a command. + + Returns: + dict[str, CommandObject]: The CommandObjects linked to this Account. + """ + return {"account": self} + def at_post_add_character(self, character: "DefaultCharacter"): """ Called after a character is added to this account's list of playable characters. @@ -1514,17 +1534,35 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): def at_cmdset_get(self, **kwargs): """ - Called just *before* cmdsets on this account are requested by - the command handler. The cmdsets are available as - `self.cmdset`. If changes need to be done on the fly to the + Called just before cmdsets on this object are requested by the + command handler. If changes need to be done on the fly to the cmdset before passing them on to the cmdhandler, this is the - place to do it. This is called also if the account currently - have no cmdsets. kwargs are usually not used unless the - cmdset is generated dynamically. + place to do it. This is called also if the object currently + have no cmdsets. + + Keyword Args: + caller (obj): The object requesting the cmdsets. + current (cmdset): The current merged cmdset. + force_init (bool): If `True`, force a re-build of the cmdset. (seems unused) + **kwargs: Arbitrary input for overloads. """ pass + def get_cmdsets(self, caller, current, **kwargs): + """ + Called by the CommandHandler to get a list of cmdsets to merge. + + Args: + caller (obj): The object requesting the cmdsets. + current (cmdset): The current merged cmdset. + **kwargs: Arbitrary input for overloads. + + Returns: + tuple: A tuple of (current, cmdsets), which is probably self.cmdset.current and self.cmdset.cmdset_stack + """ + return self.cmdset.current, list(self.cmdset.cmdset_stack) + def at_first_login(self, **kwargs): """ Called the very first time this account logs into the game. diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 78e400f7da..c0df779615 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -41,6 +41,7 @@ from twisted.internet.defer import inlineCallbacks, returnValue from twisted.internet.task import deferLater from evennia.commands.command import InterruptCommand +from evennia.commands.cmdset import CmdSet from evennia.utils import logger, utils from evennia.utils.utils import string_suggestions @@ -280,10 +281,37 @@ class ErrorReported(Exception): # Helper function +def generate_command_objects(called_by, session=None): + command_objects = dict() + command_objects.update(called_by.get_command_objects()) + if session and session is not called_by: + command_objects.update(session.get_command_objects()) + + command_objects_list = list(command_objects.values()) + command_objects_list.sort(key=lambda x: getattr(x, "cmd_order", 0)) + # sort the dictionary by priority. This can be done because Python now cares about dictionary insert order. + command_objects = {c.cmd_type: c for c in command_objects_list} + + if not command_objects: + raise RuntimeError("cmdhandler: no command objects found.") + + # the caller will be the one to receive messages and excert its permissions. + # we assign the caller with preference 'bottom up' + caller = command_objects_list[-1] + + command_objects_list_error = sorted( + command_objects_list, key=lambda x: getattr(x, "cmd_order_error", 0) + ) + + # The error_to is the default recipient for errors. Tries to make sure an account + # does not get spammed for errors while preserving character mirroring. + error_to = command_objects_list_error[-1] + + return command_objects, command_objects_list, command_objects_list_error, caller, error_to @inlineCallbacks -def get_and_merge_cmdsets(caller, session, account, obj, callertype, raw_string): +def get_and_merge_cmdsets(caller, command_objects, callertype, raw_string, report_to=None): """ Gather all relevant cmdsets and merge them. @@ -293,12 +321,11 @@ def get_and_merge_cmdsets(caller, session, account, obj, callertype, raw_string) when the user is not logged in, this will be a Session, when being OOC it will be an Account and when puppeting an object this will (often) be a Character Object. In the end it depends on where the cmdset is stored. - session (Session or None): The Session associated with caller, if any. - account (Account or None): The calling Account associated with caller, if any. - obj (Object or None): The Object associated with caller, if any. + command_objects (list): A list of sorted objects which provide cmdsets. callertype (str): This identifies caller as either "account", "object" or "session" to avoid having to do this check internally. raw_string (str): The input string. This is only used for error reporting. + report_to (Object, optional): If given, this object will receive error messages Returns: cmdset (Deferred): This deferred fires with the merged cmdset @@ -366,78 +393,47 @@ def get_and_merge_cmdsets(caller, session, account, obj, callertype, raw_string) raise ErrorReported(raw_string) @inlineCallbacks - def _get_cmdsets(obj): + def _get_cmdsets(obj, current): """ Helper method; Get cmdset while making sure to trigger all hooks safely. Returns the stack and the valid options. """ try: - yield obj.at_cmdset_get() + yield obj.at_cmdset_get(caller=caller, current=current) except Exception: _msg_err(caller, _ERROR_CMDSETS) raise ErrorReported(raw_string) try: - returnValue((obj.cmdset.current, list(obj.cmdset.cmdset_stack))) + returnValue(obj.get_cmdsets(caller=caller, current=current)) except AttributeError: returnValue(((None, None, None), [])) local_obj_cmdsets = [] - if callertype == "session": - # we are calling the command from the session level - report_to = session - current, cmdsets = yield _get_cmdsets(session) - if account: # this automatically implies logged-in - pcurrent, account_cmdsets = yield _get_cmdsets(account) - cmdsets += account_cmdsets - current = current + pcurrent - if obj: - ocurrent, obj_cmdsets = yield _get_cmdsets(obj) - current = current + ocurrent - cmdsets += obj_cmdsets + + current_cmdset = CmdSet() + object_cmdsets = list() + for cmdobj in command_objects: + current, cur_cmdsets = yield _get_cmdsets(cmdobj, current_cmdset) + if current: + current_cmdset = current_cmdset + current + if cur_cmdsets: + object_cmdsets += cur_cmdsets + match cmdobj.cmd_type: + case "object": if not current.no_objs: - local_obj_cmdsets = yield _get_local_obj_cmdsets(obj) + local_obj_cmdsets = yield _get_local_obj_cmdsets(cmdobj) if current.no_exits: # filter out all exits local_obj_cmdsets = [ cmdset for cmdset in local_obj_cmdsets if cmdset.key != "ExitCmdSet" ] - cmdsets += local_obj_cmdsets - - elif callertype == "account": - # we are calling the command from the account level - report_to = account - current, cmdsets = yield _get_cmdsets(account) - if obj: - ocurrent, obj_cmdsets = yield _get_cmdsets(obj) - current = current + ocurrent - cmdsets += obj_cmdsets - if not current.no_objs: - local_obj_cmdsets = yield _get_local_obj_cmdsets(obj) - if current.no_exits: - # filter out all exits - local_obj_cmdsets = [ - cmdset for cmdset in local_obj_cmdsets if cmdset.key != "ExitCmdSet" - ] - cmdsets += local_obj_cmdsets - - elif callertype == "object": - # we are calling the command from the object level - report_to = obj - current, cmdsets = yield _get_cmdsets(obj) - if not current.no_objs: - local_obj_cmdsets = yield _get_local_obj_cmdsets(obj) - if current.no_exits: - # filter out all exits - local_obj_cmdsets = [ - cmdset for cmdset in local_obj_cmdsets if cmdset.key != "ExitCmdSet" - ] - cmdsets += yield local_obj_cmdsets - else: - raise Exception("get_and_merge_cmdsets: callertype %s is not valid." % callertype) + object_cmdsets += local_obj_cmdsets # weed out all non-found sets - cmdsets = yield [cmdset for cmdset in cmdsets if cmdset and cmdset.key != "_EMPTY_CMDSET"] + cmdsets = yield [ + cmdset for cmdset in object_cmdsets if cmdset and cmdset.key != "_EMPTY_CMDSET" + ] # report cmdset errors to user (these should already have been logged) yield [ report_to.msg(cmdset.errmessage) for cmdset in cmdsets if cmdset.key == "_CMDSET_ERROR" @@ -552,7 +548,7 @@ def cmdhandler( """ @inlineCallbacks - def _run_command(cmd, cmdname, args, raw_cmdname, cmdset, session, account): + def _run_command(cmd, cmdname, args, raw_cmdname, cmdset, session, account, command_objects): """ Helper function: This initializes and runs the Command instance once the parser has identified it as either a normal @@ -568,6 +564,7 @@ def cmdhandler( cmdset (CmdSet): Command sert the command belongs to (if any).. session (Session): Session of caller (if any). account (Account): Account of caller (if any). + command_objects (dict): Dictionary of all command objects. Returns: deferred (Deferred): this will fire with the return of the @@ -586,6 +583,7 @@ def cmdhandler( cmd.cmdstring = cmdname # deprecated cmd.args = args cmd.cmdset = cmdset + cmd.command_objects = command_objects.copy() cmd.session = session cmd.account = account cmd.raw_string = unformatted_raw_string @@ -655,25 +653,15 @@ def cmdhandler( finally: _COMMAND_NESTING[called_by] -= 1 - session, account, obj = session, None, None - if callertype == "session": - session = called_by - account = session.account - obj = session.puppet - elif callertype == "account": - account = called_by - if session: - obj = yield session.puppet - elif callertype == "object": - obj = called_by - else: - raise RuntimeError("cmdhandler: callertype %s is not valid." % callertype) - # the caller will be the one to receive messages and excert its permissions. - # we assign the caller with preference 'bottom up' - caller = obj or account or session - # The error_to is the default recipient for errors. Tries to make sure an account - # does not get spammed for errors while preserving character mirroring. - error_to = obj or session or account + ( + command_objects, + command_objects_list, + command_objects_list_error, + caller, + error_to, + ) = generate_command_objects(called_by, session=session) + + account = command_objects.get("account", None) try: # catch bugs in cmdhandler itself try: # catch special-type commands @@ -691,7 +679,7 @@ def cmdhandler( else: # no explicit cmdobject given, figure it out cmdset = yield get_and_merge_cmdsets( - caller, session, account, obj, callertype, raw_string + caller, command_objects_list, callertype, raw_string ) if not cmdset: # this is bad and shouldn't happen. @@ -764,7 +752,9 @@ def cmdhandler( cmd = copy(cmd) # A normal command. - ret = yield _run_command(cmd, cmdname, args, raw_cmdname, cmdset, session, account) + ret = yield _run_command( + cmd, cmdname, args, raw_cmdname, cmdset, session, account, command_objects + ) returnValue(ret) except ErrorReported as exc: @@ -780,7 +770,14 @@ def cmdhandler( if syscmd: ret = yield _run_command( - syscmd, syscmd.key, sysarg, unformatted_raw_string, cmdset, session, account + syscmd, + syscmd.key, + sysarg, + unformatted_raw_string, + cmdset, + session, + account, + command_objects, ) returnValue(ret) elif sysarg: diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index da6a05e67e..2a2dd6e367 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -10,7 +10,7 @@ from django.db.models import Max, Min, Q import evennia from evennia import InterruptCommand -from evennia.commands.cmdhandler import get_and_merge_cmdsets +from evennia.commands.cmdhandler import get_and_merge_cmdsets, generate_command_objects from evennia.locks.lockhandler import LockException from evennia.objects.models import ObjectDB from evennia.prototypes import menus as olc_menus @@ -3122,8 +3122,16 @@ class CmdExamine(ObjManipCommand): def _get_cmdset_callback(current_cmdset): self.msg(self.format_output(obj, current_cmdset).strip()) + ( + command_objects, + command_objects_list, + command_objects_list_error, + caller, + error_to, + ) = generate_command_objects(obj, session=session) + get_and_merge_cmdsets( - obj, session, account, objct, mergemode, self.raw_string + obj, command_objects_list, mergemode, self.raw_string, error_to ).addCallback(_get_cmdset_callback) else: diff --git a/evennia/commands/tests.py b/evennia/commands/tests.py index dcd11b9865..bd23f94dda 100644 --- a/evennia/commands/tests.py +++ b/evennia/commands/tests.py @@ -1020,8 +1020,16 @@ class TestGetAndMergeCmdSets(TwistedTestCase, BaseEvenniaTest): a = self.cmdset_a a.no_channels = True self.set_cmdsets(self.session, a) + ( + command_objects, + command_objects_list, + command_objects_list_error, + caller, + error_to, + ) = cmdhandler.generate_command_objects(self.session) + deferred = cmdhandler.get_and_merge_cmdsets( - self.session, self.session, None, None, "session", "" + self.session, [self.session], "session", "", error_to ) def _callback(cmdset): @@ -1036,8 +1044,16 @@ class TestGetAndMergeCmdSets(TwistedTestCase, BaseEvenniaTest): a = self.cmdset_a a.no_channels = True self.set_cmdsets(self.account, a) + ( + command_objects, + command_objects_list, + command_objects_list_error, + caller, + error_to, + ) = cmdhandler.generate_command_objects(self.account) + deferred = cmdhandler.get_and_merge_cmdsets( - self.account, None, self.account, None, "account", "" + self.account, command_objects_list, "account", "", error_to ) # get_and_merge_cmdsets converts to lower-case internally. @@ -1053,7 +1069,17 @@ class TestGetAndMergeCmdSets(TwistedTestCase, BaseEvenniaTest): def test_from_object(self): self.set_cmdsets(self.obj1, self.cmdset_a) - deferred = cmdhandler.get_and_merge_cmdsets(self.obj1, None, None, self.obj1, "object", "") + ( + command_objects, + command_objects_list, + command_objects_list_error, + caller, + error_to, + ) = cmdhandler.generate_command_objects(self.obj1) + + deferred = cmdhandler.get_and_merge_cmdsets( + self.obj1, command_objects_list, "object", "", error_to + ) # get_and_merge_cmdsets converts to lower-case internally. def _callback(cmdset): @@ -1069,8 +1095,16 @@ class TestGetAndMergeCmdSets(TwistedTestCase, BaseEvenniaTest): a.no_exits = True a.no_channels = True self.set_cmdsets(self.obj1, a, b, c, d) - - deferred = cmdhandler.get_and_merge_cmdsets(self.obj1, None, None, self.obj1, "object", "") + ( + command_objects, + command_objects_list, + command_objects_list_error, + caller, + error_to, + ) = cmdhandler.generate_command_objects(self.obj1) + deferred = cmdhandler.get_and_merge_cmdsets( + self.obj1, command_objects_list, "object", "", error_to + ) def _callback(cmdset): self.assertTrue(cmdset.no_exits) @@ -1087,7 +1121,17 @@ class TestGetAndMergeCmdSets(TwistedTestCase, BaseEvenniaTest): b.duplicates = True d.duplicates = True self.set_cmdsets(self.obj1, a, b, c, d) - deferred = cmdhandler.get_and_merge_cmdsets(self.obj1, None, None, self.obj1, "object", "") + ( + command_objects, + command_objects_list, + command_objects_list_error, + caller, + error_to, + ) = cmdhandler.generate_command_objects(self.obj1, session=None) + + deferred = cmdhandler.get_and_merge_cmdsets( + self.obj1, command_objects_list, "object", "", error_to + ) def _callback(cmdset): self.assertEqual(len(cmdset.commands), 9) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 67ee70b9c8..8b3c55dba6 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -204,6 +204,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ + # Determines which order command sets begin to be assembled from. + # Objects are usually third. + cmd_order = 100 + cmd_order_error = 100 + cmd_type = "object" + # Used for sorting / filtering in inventories / room contents. _content_types = ("object",) @@ -256,6 +262,24 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ return self.sessions.count() + def get_command_objects(self) -> dict[str, "CommandObject"]: + """ + Overrideable method which returns a dictionary of all the kinds of CommandObjects + linked to this Object. + + In all normal cases, that's the Object itself, and maybe an Account if the Object + is being puppeted. + + The cmdhandler uses this to determine available cmdsets when executing a command. + + Returns: + dict[str, CommandObject]: The CommandObjects linked to this Object. + """ + out = {"object": self} + if self.account: + out["account"] = self.account + return out + @property def is_superuser(self): """ @@ -1601,12 +1625,28 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): have no cmdsets. Keyword Args: - caller (Session, Object or Account): The caller requesting - this cmdset. + caller (obj): The object requesting the cmdsets. + current (cmdset): The current merged cmdset. + force_init (bool): If `True`, force a re-build of the cmdset. (seems unused) + **kwargs: Arbitrary input for overloads. """ pass + def get_cmdsets(self, caller, current, **kwargs): + """ + Called by the CommandHandler to get a list of cmdsets to merge. + + Args: + caller (obj): The object requesting the cmdsets. + current (cmdset): The current merged cmdset. + **kwargs: Arbitrary input for overloads. + + Returns: + tuple: A tuple of (current, cmdsets), which is probably self.cmdset.current and self.cmdset.cmdset_stack + """ + return self.cmdset.current, list(self.cmdset.cmdset_stack) + def at_pre_puppet(self, account, session=None, **kwargs): """ Called just before an Account connects to this object to puppet diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 2836609c57..50777ad61e 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -46,6 +46,12 @@ class ServerSession(_BASE_SESSION_CLASS): """ + # Determines which order command sets begin to be assembled from. + # Sessions are usually first. + cmd_order = 0 + cmd_order_error = 50 + cmd_type = "session" + def __init__(self): """ Initiate to avoid AttributeErrors down the line @@ -64,6 +70,26 @@ class ServerSession(_BASE_SESSION_CLASS): cmdset_storage = property(__cmdset_storage_get, __cmdset_storage_set) + def get_command_objects(self) -> dict[str, "CommandObject"]: + """ + Overrideable method which returns a dictionary of all the kinds of CommandObjects + linked to this ServerSession. + + In all normal cases, that's the Session itself, and possibly an account and puppeted + object. + + The cmdhandler uses this to determine available cmdsets when executing a command. + + Returns: + dict[str, CommandObject]: The CommandObjects linked to this Object. + """ + out = {"session": self} + if self.account: + out["account"] = self.account + if self.puppet: + out["puppet"] = self.puppet + return out + @property def id(self): return self.sessid @@ -376,12 +402,35 @@ class ServerSession(_BASE_SESSION_CLASS): def at_cmdset_get(self, **kwargs): """ - A dummy hook all objects with cmdsets need to have + Called just before cmdsets on this object are requested by the + command handler. If changes need to be done on the fly to the + cmdset before passing them on to the cmdhandler, this is the + place to do it. This is called also if the object currently + have no cmdsets. + + Keyword Args: + caller (obj): The object requesting the cmdsets. + current (cmdset): The current merged cmdset. + force_init (bool): If `True`, force a re-build of the cmdset. (seems unused) + **kwargs: Arbitrary input for overloads. """ - pass + def get_cmdsets(self, caller, current, **kwargs): + """ + Called by the CommandHandler to get a list of cmdsets to merge. + + Args: + caller (obj): The object requesting the cmdsets. + current (cmdset): The current merged cmdset. + **kwargs: Arbitrary input for overloads. + + Returns: + tuple: A tuple of (current, cmdsets), which is probably self.cmdset.current and self.cmdset.cmdset_stack + """ + return self.cmdset.current, list(self.cmdset.cmdset_stack) + # Mock db/ndb properties for allowing easy storage on the session # (note that no databse is involved at all here. session.db.attr = # value just saves a normal property in memory, just like ndb).