Merge pull request #3346 from volundmush/cmdhandler_refactor

Streamlined cmdhandler to support more extension.
This commit is contained in:
Griatch 2023-12-10 17:34:01 +01:00 committed by GitHub
commit 3062a31363
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 289 additions and 111 deletions

View file

@ -272,6 +272,12 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
"""
# Determines which order command sets begin to be assembled from.
# Accounts are usually second.
cmdset_provider_order = 50
cmdset_provider_error_order = 0
cmdset_provider_type = "account"
objects = AccountManager()
# Used by account.create_character() to choose default typeclass for characters.
@ -309,6 +315,19 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
def characters(self):
return CharactersHandler(self)
def get_cmdset_providers(self) -> dict[str, "CmdSetProvider"]:
"""
Overrideable method which returns a dictionary of every kind of object which
has a cmdsethandler linked to this Account, and should participate in cmdset
merging.
Accounts have no way of being aware of anything besides themselves, unfortunately.
Returns:
dict[str, CmdSetProvider]: The CmdSetProviders linked to this Object.
"""
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 +1533,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.

View file

@ -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
@ -150,9 +151,13 @@ _GET_INPUT = None
# helper functions
def err_helper(raw_string, cmdid=None):
if cmdid is not None:
return raw_string, {"cmdid": cmdid}
return raw_string
def _msg_err(receiver, stringtuple):
def _msg_err(receiver, stringtuple, cmdid=None):
"""
Helper function for returning an error to the caller.
@ -168,19 +173,16 @@ def _msg_err(receiver, stringtuple):
tracestring = format_exc()
logger.log_trace()
if _IN_GAME_ERRORS:
receiver.msg(
string.format(
traceback=tracestring, errmsg=stringtuple[0].strip(), timestamp=timestamp
).strip()
)
out = string.format(
traceback=tracestring, errmsg=stringtuple[0].strip(), timestamp=timestamp
).strip()
else:
receiver.msg(
string.format(
traceback=tracestring.splitlines()[-1],
errmsg=stringtuple[1].strip(),
timestamp=timestamp,
).strip()
)
out = string.format(
traceback=tracestring.splitlines()[-1],
errmsg=stringtuple[1].strip(),
timestamp=timestamp,
).strip()
receiver.msg(err_helper(out, cmdid=cmdid))
def _process_input(caller, prompt, result, cmd, generator):
@ -280,10 +282,39 @@ class ErrorReported(Exception):
# Helper function
def generate_cmdset_providers(called_by, session=None):
cmdset_providers = dict()
cmdset_providers.update(called_by.get_cmdset_providers())
if session and session is not called_by:
cmdset_providers.update(session.get_cmdset_providers())
cmdset_providers_list = list(cmdset_providers.values())
cmdset_providers_list.sort(key=lambda x: getattr(x, "cmdset_provider_order", 0))
# sort the dictionary by priority. This can be done because Python now cares about dictionary insert order.
cmdset_providers = {c.cmdset_provider_type: c for c in cmdset_providers_list}
if not cmdset_providers:
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 = cmdset_providers_list[-1]
cmdset_providers_errors_list = sorted(
cmdset_providers_list, key=lambda x: getattr(x, "cmdset_provider_error_order", 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 = cmdset_providers_errors_list[-1]
return cmdset_providers, cmdset_providers_list, cmdset_providers_errors_list, caller, error_to
@inlineCallbacks
def get_and_merge_cmdsets(caller, session, account, obj, callertype, raw_string):
def get_and_merge_cmdsets(
caller, cmdset_providers, callertype, raw_string, report_to=None, cmdid=None
):
"""
Gather all relevant cmdsets and merge them.
@ -293,12 +324,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.
cmdset_providers (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,81 +396,52 @@ 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 cmdset_providers:
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.cmdset_provider_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"
report_to.msg(err_helper(cmdset.errmessage, cmdid=cmdid))
for cmdset in cmdsets
if cmdset.key == "_CMDSET_ERROR"
]
if cmdsets:
@ -550,9 +551,10 @@ def cmdhandler(
default Evennia.
"""
cmdid = kwargs.get("cmdid", None)
@inlineCallbacks
def _run_command(cmd, cmdname, args, raw_cmdname, cmdset, session, account):
def _run_command(cmd, cmdname, args, raw_cmdname, cmdset, session, account, cmdset_providers):
"""
Helper function: This initializes and runs the Command
instance once the parser has identified it as either a normal
@ -568,6 +570,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).
cmdset_providers (dict): Dictionary of all cmdset-providing objects.
Returns:
deferred (Deferred): this will fire with the return of the
@ -586,6 +589,7 @@ def cmdhandler(
cmd.cmdstring = cmdname # deprecated
cmd.args = args
cmd.cmdset = cmdset
cmd.cmdset_providers = cmdset_providers.copy()
cmd.session = session
cmd.account = account
cmd.raw_string = unformatted_raw_string
@ -655,25 +659,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
(
cmdset_providers,
cmdset_providers_list,
cmdset_providers_list_error,
caller,
error_to,
) = generate_cmdset_providers(called_by, session=session)
account = cmdset_providers.get("account", None)
try: # catch bugs in cmdhandler itself
try: # catch special-type commands
@ -691,7 +685,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, cmdset_providers_list, callertype, raw_string, cmdid=cmdid
)
if not cmdset:
# this is bad and shouldn't happen.
@ -764,7 +758,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, cmdset_providers
)
returnValue(ret)
except ErrorReported as exc:
@ -780,17 +776,24 @@ 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,
cmdset_providers,
)
returnValue(ret)
elif sysarg:
# return system arg
error_to.msg(exc.sysarg)
error_to.msg(err_helper(exc.sysarg, cmdid=cmdid))
except NoCmdSets:
# Critical error.
logger.log_err("No cmdsets found: %s" % caller)
error_to.msg(_ERROR_NOCMDSETS)
error_to.msg(err_helper(_ERROR_NOCMDSETS, cmdid=cmdid))
except Exception:
# We should not end up here. If we do, it's a programming bug.

View file

@ -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_cmdset_providers
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_cmdset_providers(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:

View file

@ -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_cmdset_providers(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_cmdset_providers(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_cmdset_providers(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_cmdset_providers(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_cmdset_providers(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)

View file

@ -204,6 +204,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
"""
# Determines which order command sets begin to be assembled from.
# Objects are usually third.
cmdset_provider_order = 100
cmdset_provider_error_order = 100
cmdset_provider_type = "object"
# Used for sorting / filtering in inventories / room contents.
_content_types = ("object",)
@ -256,6 +262,22 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
"""
return self.sessions.count()
def get_cmdset_providers(self) -> dict[str, "CmdSetProvider"]:
"""
Overrideable method which returns a dictionary of every kind of object which
has a cmdsethandler linked to this Object, and should participate in cmdset
merging.
Objects might be aware of an Account. Otherwise, just themselves, by default.
Returns:
dict[str, CmdSetProvider]: The CmdSetProviders linked to this Object.
"""
out = {"object": self}
if self.account:
out["account"] = self.account
return out
@property
def is_superuser(self):
"""
@ -1601,12 +1623,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

View file

@ -46,6 +46,12 @@ class ServerSession(_BASE_SESSION_CLASS):
"""
# Determines which order command sets begin to be assembled from.
# Sessions are usually first.
cmdset_provider_order = 0
cmdset_provider_error_order = 50
cmdset_provider_type = "session"
def __init__(self):
"""
Initiate to avoid AttributeErrors down the line
@ -64,6 +70,25 @@ class ServerSession(_BASE_SESSION_CLASS):
cmdset_storage = property(__cmdset_storage_get, __cmdset_storage_set)
def get_cmdset_providers(self) -> dict[str, "CmdSetProvider"]:
"""
Overrideable method which returns a dictionary of every kind of object which
has a cmdsethandler linked to this ServerSession, and should participate in cmdset
merging.
In all normal cases, that's the Session itself, and possibly an account and puppeted
object.
Returns:
dict[str, CmdSetProvider]: The CmdSetProviders linked to this Object.
"""
out = {"session": self}
if self.account:
out["account"] = self.account
if self.puppet:
out["object"] = self.puppet
return out
@property
def id(self):
return self.sessid
@ -376,12 +401,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).