diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 65fca532f..b30a4bdd6 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -95,7 +95,7 @@ _ERROR_NOCMDSETS = "No command sets found! This is a sign of a critical bug." \ "\nsome other means for assistance." _ERROR_CMDHANDLER = "{traceback}\n"\ - "Above traceback is from a Command handler bug." \ + "Above traceback is from a Command handler bug. " \ "Please file a bug report with the Evennia project." _ERROR_RECURSION_LIMIT = "Command recursion limit ({recursion_limit}) " \ diff --git a/evennia/commands/connection_screen.py b/evennia/commands/connection_screen.py index c164fe62f..f6160db90 100644 --- a/evennia/commands/connection_screen.py +++ b/evennia/commands/connection_screen.py @@ -1,6 +1,6 @@ # # This is Evennia's default connection screen. It is imported -# and run from world/connection_screens.py. +# and run from server/conf/connection_screens.py. # from django.conf import settings diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 32161c927..d78789700 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1093,7 +1093,8 @@ class CmdName(ObjManipCommand): Usage: @name obj = name;alias1;alias2 - Rename an object to something new. + Rename an object to something new. Use *obj to + rename a player. """ @@ -1107,12 +1108,30 @@ class CmdName(ObjManipCommand): caller = self.caller if not self.args: - string = "Usage: @name = [;alias;alias;...]" - caller.msg(string) + caller.msg("Usage: @name = [;alias;alias;...]") return if self.lhs_objs: objname = self.lhs_objs[0]['name'] + if objname.startswith("*"): + # player mode + obj = caller.player.search(objname.lstrip("*")) + if obj: + if self.rhs_objs[0]['aliases']: + caller.msg("Players can't have aliases.") + return + newname = self.rhs + if not newname: + caller.msg("No name defined!") + return + if not obj.access(caller, "edit"): + caller.mgs("You don't have right to edit this player %s." % obj) + return + obj.username = newname + obj.save() + caller.msg("Player's name changed to '%s'." % newname) + return + # object search, also with * obj = caller.search(objname) if not obj: return @@ -1125,6 +1144,9 @@ class CmdName(ObjManipCommand): if not newname and not aliases: caller.msg("No names or aliases defined!") return + if not obj.access(caller, "edit"): + caller.msg("You don't have the right to edit %s." % obj) + return # change the name and set aliases: if newname: obj.name = newname diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index ab66000f4..d89f23f68 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -519,17 +519,15 @@ class CmdChannelCreate(MuxPlayerCommand): channame = lhs aliases = None if ';' in lhs: - channame, aliases = [part.strip().lower() - for part in lhs.split(';', 1) if part.strip()] - aliases = [alias.strip().lower() - for alias in aliases.split(';') if alias.strip()] + channame, aliases = lhs.split(';', 1) + aliases = [alias.strip().lower() for alias in aliases.split(';')] channel = ChannelDB.objects.channel_search(channame) if channel: self.msg("A channel with that name already exists.") return # Create and set the channel up lockstring = "send:all();listen:all();control:id(%s)" % caller.id - new_chan = create.create_channel(channame, + new_chan = create.create_channel(channame.strip(), aliases, description, locks=lockstring) diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index c5c9b431a..2d7dd225d 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -74,6 +74,7 @@ class CmdHelp(Command): """ key = "help" locks = "cmd:all()" + arg_regex = r"\s|$" # this is a special cmdhandler flag that makes the cmdhandler also pack # the current cmdset with the call to self.func(). diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index c22b38bd7..331d29409 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -194,23 +194,23 @@ class CmdPy(MuxCommand): t0 = timemeasure() ret = eval(pycode_compiled, {}, available_vars) t1 = timemeasure() - duration = " (%.4f ms)" % ((t1 - t0) * 1000) + duration = " (runtime ~ %.4f ms)" % ((t1 - t0) * 1000) else: ret = eval(pycode_compiled, {}, available_vars) if mode == "eval": - ret = "{n<<< %s%s" % (str(ret), duration) + ret = "<<< %s%s" % (str(ret), duration) else: - ret = "{n<<< Done.%s" % duration + ret = "<<< Done (use self.msg() if you want to catch output)%s" % duration except Exception: errlist = traceback.format_exc().split('\n') if len(errlist) > 4: errlist = errlist[4:] - ret = "\n".join("{n<<< %s" % line for line in errlist if line) + ret = "\n".join("<<< %s" % line for line in errlist if line) try: - self.msg(ret, sessid=self.sessid) + self.msg(ret, sessid=self.sessid, raw=True) except TypeError: - self.msg(ret) + self.msg(ret, raw=True) # helper function. Kept outside so it can be imported and run diff --git a/evennia/contrib/menu_login.py b/evennia/contrib/menu_login.py index c70972a9c..7868aa998 100644 --- a/evennia/contrib/menu_login.py +++ b/evennia/contrib/menu_login.py @@ -15,14 +15,14 @@ Install is simple: To your settings file, add/edit the line: -CMDSET_UNLOGGEDIN = "contrib.menu_login.UnloggedInCmdSet" +CMDSET_UNLOGGEDIN = "contrib.menu_login.UnloggedinCmdSet" That's it. Reload the server and try to log in to see it. You will want to change the login "graphic", which defaults to give information about commands which are not used in this version of the login. You can change the screen used by editing -`mygame/server/conf/connection_screens.py`. +`$GAME_DIR/server/conf/connection_screens.py`. """ @@ -323,13 +323,13 @@ node3 = MenuNode("node3", text=LOGIN_SCREEN_HELP, # access commands -class UnloggedInCmdSet(CmdSet): +class UnloggedinCmdSet(CmdSet): "Cmdset for the unloggedin state" key = "DefaultUnloggedin" priority = 0 def at_cmdset_creation(self): - "Called when cmdset is first created" + "Called when cmdset is first created." self.add(CmdUnloggedinLook()) @@ -337,7 +337,7 @@ class CmdUnloggedinLook(Command): """ An unloggedin version of the look command. This is called by the server when the player first connects. It sets up the menu before handing off - to the menu's own look command.. + to the menu's own look command. """ key = CMD_LOGINSTART # obs, this should NOT have aliases for look or l, this will clash with the menu version! diff --git a/evennia/contrib/menusystem.py b/evennia/contrib/menusystem.py index 12346bb2a..3bc52c2fe 100644 --- a/evennia/contrib/menusystem.py +++ b/evennia/contrib/menusystem.py @@ -281,7 +281,7 @@ class MenuNode(object): text (str, optional): The text that will be displayed at top when viewing this node. Kwargs: - links (list): A liist of keys for unique menunodes this is connected to. + links (list): A list of keys for unique menunodes this is connected to. The actual keys will not printed - keywords will be used (or a number) linktexts (list)- A list of texts to describe the links. Must diff --git a/evennia/game_template/server/conf/settings.py b/evennia/game_template/server/conf/settings.py index 6ea4f4ad9..9a29edd53 100644 --- a/evennia/game_template/server/conf/settings.py +++ b/evennia/game_template/server/conf/settings.py @@ -62,6 +62,8 @@ DATABASES = {{ ###################################################################### # Django web features +# (don't remove these entries, they are needed to override the default +# locations with your actual GAME_DIR locations at run-time) ###################################################################### # Absolute path to the directory that holds file uploads from web apps. diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index ff3e8f628..0370fa0f2 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -370,7 +370,7 @@ class DefaultObject(ObjectDB): if quiet: return results - return _AT_SEARCH_RESULT(self, searchdata, results, global_search=True) + return _AT_SEARCH_RESULT(results, self, query=searchdata) def execute_cmd(self, raw_string, sessid=None, **kwargs): """ diff --git a/evennia/server/amp.py b/evennia/server/amp.py index 453326975..857dabfca 100644 --- a/evennia/server/amp.py +++ b/evennia/server/amp.py @@ -250,7 +250,7 @@ class MsgPortal2Server(amp.Command): """ key = "MsgPortal2Server" - arguments = [('data', Compressed())] + arguments = [('packed_data', Compressed())] errors = [(Exception, 'EXCEPTION')] response = [] @@ -261,7 +261,7 @@ class MsgServer2Portal(amp.Command): """ key = "MsgServer2Portal" - arguments = [('data', Compressed())] + arguments = [('packed_data', Compressed())] errors = [(Exception, 'EXCEPTION')] response = [] @@ -275,7 +275,7 @@ class AdminPortal2Server(amp.Command): """ key = "AdminPortal2Server" - arguments = [('data', Compressed())] + arguments = [('packed_data', Compressed())] errors = [(Exception, 'EXCEPTION')] response = [] @@ -289,7 +289,7 @@ class AdminServer2Portal(amp.Command): """ key = "AdminServer2Portal" - arguments = [('data', Compressed())] + arguments = [('packed_data', Compressed())] errors = [(Exception, 'EXCEPTION')] response = [] @@ -362,7 +362,7 @@ class AMPProtocol(amp.AMP): sessdata = self.factory.portal.sessions.get_all_sync_data() self.send_AdminPortal2Server(0, PSYNC, - data=sessdata) + sessiondata=sessdata) self.factory.portal.sessions.at_server_connection() if hasattr(self.factory, "server_restart_mode"): del self.factory.server_restart_mode @@ -399,53 +399,49 @@ class AMPProtocol(amp.AMP): (sessid, kwargs). """ - batch = dumps((sessid, kwargs)) return self.callRemote(command, - data=batch).addErrback(self.errback, command.key) + packed_data=dumps((sessid, kwargs)) + ).addErrback(self.errback, command.key) # Message definition + helper methods to call/create each message type # Portal -> Server Msg @MsgPortal2Server.responder - def server_receive_msgportal2server(self, data): + def server_receive_msgportal2server(self, packed_data): """ Receives message arriving to server. This method is executed on the Server. Args: - data (str): Data to receive (a pickled tuple (sessid,kwargs)) + packed_data (str): Data to receive (a pickled tuple (sessid,kwargs)) """ - sessid, kwargs = loads(data) + sessid, kwargs = loads(packed_data) #print "msg portal -> server (server side):", sessid, msg, loads(ret["data"]) - self.factory.server.sessions.data_in(sessid, - text=kwargs["msg"], - data=kwargs["data"]) + self.factory.server.sessions.data_in(sessid, **kwargs) return {} - def send_MsgPortal2Server(self, sessid, msg="", data=""): + def send_MsgPortal2Server(self, sessid, text="", **kwargs): """ Access method called by the Portal and executed on the Portal. Args: sessid (int): Unique Session id. msg (str): Message to send over the wire. - data (str, optional): Optional data. + kwargs (any, optional): Optional data. Returns: deferred (Deferred): Asynchronous return. """ #print "msg portal->server (portal side):", sessid, msg, data - return self.send_data(MsgPortal2Server, sessid, - msg=msg, - data=data) + return self.send_data(MsgPortal2Server, sessid, text=text, **kwargs) # Server -> Portal message @MsgServer2Portal.responder - def portal_receive_server2portal(self, data): + def portal_receive_server2portal(self, packed_data): """ Receives message arriving to Portal from Server. This method is executed on the Portal. @@ -456,17 +452,15 @@ class AMPProtocol(amp.AMP): before continuing. Args: - data (str): Pickled data (sessid, kwargs) coming over the wire. + packed_data (str): Pickled data (sessid, kwargs) coming over the wire. """ - sessid, kwargs = loads(data) + sessid, kwargs = loads(packed_data) #print "msg server->portal (portal side):", sessid, ret["text"], loads(ret["data"]) - self.factory.portal.sessions.data_out(sessid, - text=kwargs["msg"], - data=kwargs["data"]) + self.factory.portal.sessions.data_out(sessid, **kwargs) return {} - def send_MsgServer2Portal(self, sessid, msg="", data=""): + def send_MsgServer2Portal(self, sessid, text="", **kwargs): """ Access method - executed on the Server for sending data to Portal. @@ -474,39 +468,37 @@ class AMPProtocol(amp.AMP): Args: sessid (int): Unique Session id. msg (str, optional): Message to send over the wire. - data (str, optional): Extra data. + kwargs (any, optiona): Extra data. """ #print "msg server->portal (server side):", sessid, msg, data - return self.send_data(MsgServer2Portal, sessid, msg=msg, data=data) + return self.send_data(MsgServer2Portal, sessid, text=text, **kwargs) # Server administration from the Portal side @AdminPortal2Server.responder - def server_receive_adminportal2server(self, data): + def server_receive_adminportal2server(self, packed_data): """ Receives admin data from the Portal (allows the portal to perform admin operations on the server). This is executed on the Server. Args: - data (str): Data to send (often a part of a batch) + packed_data (str): Incoming, pickled data. """ #print "serveradmin (server side):", hashid, ipart, nparts - sessid, kwargs = loads(data) - - operation = kwargs["operation"] - data = kwargs["data"] + sessid, kwargs = loads(packed_data) + operation = kwargs.pop("operation", "") server_sessionhandler = self.factory.server.sessions #print "serveradmin (server side):", sessid, ord(operation), data if operation == PCONN: # portal_session_connect # create a new session and sync it - server_sessionhandler.portal_connect(data) + server_sessionhandler.portal_connect(kwargs.get("sessiondata")) elif operation == PCONNSYNC: #portal_session_sync - server_sessionhandler.portal_session_sync(data) + server_sessionhandler.portal_session_sync(kwargs.get("sessiondata")) elif operation == PDISCONN: # portal_session_disconnect # session closed from portal side @@ -518,12 +510,12 @@ class AMPProtocol(amp.AMP): # contains a dict {sessid: {arg1:val1,...}} # representing the attributes to sync for each # session. - server_sessionhandler.portal_sessions_sync(data) + server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata")) else: raise Exception("operation %(op)s not recognized." % {'op': operation}) return {} - def send_AdminPortal2Server(self, sessid, operation="", data=""): + def send_AdminPortal2Server(self, sessid, operation="", **kwargs): """ Send Admin instructions from the Portal to the Server. Executed @@ -533,42 +525,41 @@ class AMPProtocol(amp.AMP): sessid (int): Session id. operation (char, optional): Identifier for the server operation, as defined by the global variables in `evennia/server/amp.py`. - data (str, optional): Data going into the adminstrative operation. + data (str or dict, optional): Data used in the administrative operation. """ #print "serveradmin (portal side):", sessid, ord(operation), data - return self.send_data(AdminPortal2Server, sessid, operation=operation, data=data) + return self.send_data(AdminPortal2Server, sessid, operation=operation, **kwargs) # Portal administraton from the Server side @AdminServer2Portal.responder - def portal_receive_adminserver2portal(self, data): + def portal_receive_adminserver2portal(self, packed_data): """ Receives and handles admin operations sent to the Portal This is executed on the Portal. Args: - data (str): Data received, a pickled tuple (sessid, kwargs). + packed_data (str): Data received, a pickled tuple (sessid, kwargs). """ #print "portaladmin (portal side):", sessid, ord(operation), data - sessid, kwargs = loads(data) - operation = kwargs["operation"] - data = kwargs["data"] + sessid, kwargs = loads(packed_data) + operation = kwargs.pop("operation") portal_sessionhandler = self.factory.portal.sessions if operation == SLOGIN: # server_session_login # a session has authenticated; sync it. - portal_sessionhandler.server_logged_in(sessid, data) + portal_sessionhandler.server_logged_in(sessid, kwargs.get("sessiondata")) elif operation == SDISCONN: # server_session_disconnect # the server is ordering to disconnect the session - portal_sessionhandler.server_disconnect(sessid, reason=data) + portal_sessionhandler.server_disconnect(sessid, reason=kwargs.get("reason")) elif operation == SDISCONNALL: # server_session_disconnect_all # server orders all sessions to disconnect - portal_sessionhandler.server_disconnect_all(reason=data) + portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason")) elif operation == SSHUTD: # server_shutdown # the server orders the portal to shut down @@ -577,18 +568,18 @@ class AMPProtocol(amp.AMP): elif operation == SSYNC: # server_session_sync # server wants to save session data to the portal, # maybe because it's about to shut down. - portal_sessionhandler.server_session_sync(data) + portal_sessionhandler.server_session_sync(kwargs.get("sessiondata")) # set a flag in case we are about to shut down soon self.factory.server_restart_mode = True elif operation == SCONN: # server_force_connection (for irc/imc2 etc) - portal_sessionhandler.server_connect(**data) + portal_sessionhandler.server_connect(**kwargs) else: raise Exception("operation %(op)s not recognized." % {'op': operation}) return {} - def send_AdminServer2Portal(self, sessid, operation="", data=""): + def send_AdminServer2Portal(self, sessid, operation="", **kwargs): """ Administrative access method called by the Server to send an instruction to the Portal. @@ -598,16 +589,15 @@ class AMPProtocol(amp.AMP): operation (char, optional): Identifier for the server operation, as defined by the global variables in `evennia/server/amp.py`. - data (str, optional): Data going into the adminstrative - operation. + data (str or dict, optional): Data going into the adminstrative. """ - return self.send_data(AdminServer2Portal, sessid, operation=operation, data=data) + return self.send_data(AdminServer2Portal, sessid, operation=operation, **kwargs) # Extra functions @FunctionCall.responder - def receive_functioncall(self, module, function, args, **kwargs): + def receive_functioncall(self, module, function, func_args, func_kwargs): """ This allows Portal- and Server-process to call an arbitrary function in the other process. It is intended for use by @@ -618,12 +608,12 @@ class AMPProtocol(amp.AMP): `function` to call. function (str): The name of the function to call in `module`. - args, kwargs (any): These will be used as args/kwargs to - `function`. + func_args (str): Pickled args tuple for use in `function` call. + func_kwargs (str): Pickled kwargs dict for use in `function` call. """ - args = loads(args) - kwargs = loads(kwargs) + args = loads(func_args) + kwargs = loads(func_kwargs) # call the function (don't catch tracebacks here) result = variable_from_module(module, function)(*args, **kwargs) diff --git a/evennia/server/portal/portalsessionhandler.py b/evennia/server/portal/portalsessionhandler.py index 4f840db5f..59f7544fc 100644 --- a/evennia/server/portal/portalsessionhandler.py +++ b/evennia/server/portal/portalsessionhandler.py @@ -114,7 +114,7 @@ class PortalSessionHandler(SessionHandler): #print "connecting", session.sessid, " number:", len(self.sessions) self.portal.amp_protocol.send_AdminPortal2Server(session.sessid, operation=PCONN, - data=sessdata) + sessiondata=sessdata) def sync(self, session): """ @@ -143,7 +143,7 @@ class PortalSessionHandler(SessionHandler): "server_data",)) self.portal.amp_protocol.send_AdminPortal2Server(session.sessid, operation=PCONNSYNC, - data=sessdata) + sessiondata=sessdata) def disconnect(self, session): """ @@ -390,7 +390,6 @@ class PortalSessionHandler(SessionHandler): # data throttle (anti DoS measure) now = time() dT = now - self.command_counter_reset - print(" command rate:", _MAX_COMMAND_RATE / dT, dT, self.command_counter) self.command_counter = 0 self.command_counter_reset = now self.command_overflow = dT < 1.0 @@ -402,8 +401,8 @@ class PortalSessionHandler(SessionHandler): # relay data to Server self.command_counter += 1 self.portal.amp_protocol.send_MsgPortal2Server(session.sessid, - msg=text, - data=kwargs) + text=text, + **kwargs) else: # called by the callLater callback if self.command_overflow: diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index d56b68e56..492e58256 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -208,12 +208,15 @@ class ServerSession(Session): idle timers and command counters. """ + # Idle time used for timeout calcs. + self.cmd_last = time() + # Store the timestamp of the user's last command. if not idle: # Increment the user's command counter. self.cmd_total += 1 # Player-visible idle time, not used in idle timeout calcs. - self.cmd_last_visible = time() + self.cmd_last_visible = self.cmd_last def data_in(self, text=None, **kwargs): """ diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index f86e33166..0d5163910 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -282,11 +282,8 @@ class ServerSessionHandler(SessionHandler): the Server. """ - data = {"protocol_path":protocol_path, - "config":configdict} - self.server.amp_protocol.send_AdminServer2Portal(0, - operation=SCONN, - data=data) + self.server.amp_protocol.send_AdminServer2Portal(0, operation=SCONN, + protocol_path=protocol_path, config=configdict) def portal_shutdown(self): """ @@ -294,8 +291,7 @@ class ServerSessionHandler(SessionHandler): """ self.server.amp_protocol.send_AdminServer2Portal(0, - operation=SSHUTD, - data="") + operation=SSHUTD) def login(self, session, player, testmode=False): """ @@ -338,14 +334,12 @@ class ServerSessionHandler(SessionHandler): string = "Logged in: {player} {address} ({nsessions} session(s) total)" string = string.format(player=player,address=session.address, nsessions=nsess) session.log(string) - session.logged_in = True # sync the portal to the session - sessdata = {"logged_in": True} if not testmode: self.server.amp_protocol.send_AdminServer2Portal(session.sessid, operation=SLOGIN, - data=sessdata) + sessiondata={"logged_in": True}) player.at_post_login(sessid=session.sessid) def disconnect(self, session, reason=""): @@ -375,7 +369,7 @@ class ServerSessionHandler(SessionHandler): # inform portal that session should be closed. self.server.amp_protocol.send_AdminServer2Portal(sessid, operation=SDISCONN, - data=reason) + reason=reason) def all_sessions_portal_sync(self): """ @@ -386,7 +380,7 @@ class ServerSessionHandler(SessionHandler): sessdata = self.get_all_sync_data() return self.server.amp_protocol.send_AdminServer2Portal(0, operation=SSYNC, - data=sessdata) + sessiondata=sessdata) def disconnect_all_sessions(self, reason="You have been disconnected."): """ @@ -402,7 +396,7 @@ class ServerSessionHandler(SessionHandler): # tell portal to disconnect all sessions self.server.amp_protocol.send_AdminServer2Portal(0, operation=SDISCONNALL, - data=reason) + reason=reason) def disconnect_duplicate_sessions(self, curr_session, reason=_("Logged in from elsewhere. Disconnecting.")): @@ -586,8 +580,8 @@ class ServerSessionHandler(SessionHandler): # send to all found sessions for session in sessions: self.server.amp_protocol.send_MsgServer2Portal(sessid=session.sessid, - msg=text, - data=kwargs) + text=text, + **kwargs) def data_in(self, sessid, text="", **kwargs): """ diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index c328d4e96..96a24c347 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -45,23 +45,29 @@ entered to get to this node). The node function code will only be executed once per node-visit and the system will accept nodes with both one or two arguments interchangeably. +The menu tree itself is available on the caller as +`caller.ndb._menutree`. This makes it a convenient place to store +temporary state variables between nodes, since this NAttribute is +deleted when the menu is exited. + The return values must be given in the above order, but each can be returned as None as well. If the options are returned as None, the menu is immediately exited and the default "look" command is called. - text (str, tuple or None): Text shown at this node. If a tuple, the second - element in the tuple is a help text to display at this node when - the user enters the menu help command there. - options (tuple, dict or None): ( {'key': name, # can also be a list of aliases. A special key is "_default", which - # marks this option as the default fallback when no other - # option matches the user input. - 'desc': description, # option description - 'goto': nodekey, # node to go to when chosen - 'exec': nodekey, # node or callback to trigger as callback when chosen. If a node - # key is given the node will be executed once but its return u - # values are ignored. If a callable is given, it must accept - # one or two args, like any node. - {...}, ...) + text (str, tuple or None): Text shown at this node. If a tuple, the + second element in the tuple is a help text to display at this + node when the user enters the menu help command there. + options (tuple, dict or None): ( + {'key': name, # can also be a list of aliases. A special key is + # "_default", which marks this option as the default + # fallback when no other option matches the user input. + 'desc': description, # optional description + 'goto': nodekey, # node to go to when chosen + 'exec': nodekey}, # node or callback to trigger as callback when chosen. + # If a node key is given, the node will be executed once + # but its return values are ignored. If a callable is + # given, it must accept one or two args, like any node. + {...}, ...) If key is not given, the option will automatically be identified by its number 1..N. @@ -122,9 +128,9 @@ The menu tree is exited either by using the in-menu quit command or by reaching a node without any options. -For a menu demo, import CmdTestDemo from this module and add it to -your default cmdset. Run it with this module, like `testdemo -evennia.utils.evdemo`. +For a menu demo, import CmdTestMenu from this module and add it to +your default cmdset. Run it with this module, like `testmenu +evennia.utils.evmenu`. """ from __future__ import print_function @@ -176,65 +182,25 @@ class EvMenuError(RuntimeError): class CmdEvMenuNode(Command): """ Menu options. - """ - key = "look" - aliases = ["l", _CMD_NOMATCH, _CMD_NOINPUT] + key = _CMD_NOINPUT + aliases = [_CMD_NOMATCH] locks = "cmd:all()" help_category = "Menu" def func(self): """ Implement all menu commands. - """ caller = self.caller menu = caller.ndb._menutree if not menu: err = "Menu object not found as %s.ndb._menutree!" % (caller) - self.caller.msg(err) + caller.msg(err) raise EvMenuError(err) - # flags and data - raw_string = self.raw_string - cmd = raw_string.strip().lower() - options = menu.options - allow_quit = menu.allow_quit - cmd_on_quit = menu.cmd_on_quit - default = menu.default - - print("cmd, options:", cmd, options) - if cmd in options: - # this will overload the other commands - # if it has the same name! - goto, callback = options[cmd] - if callback: - menu.callback(callback, raw_string) - if goto: - menu.goto(goto, raw_string) - elif cmd in ("look", "l"): - caller.msg(menu.nodetext) - elif cmd in ("help", "h"): - caller.msg(menu.helptext) - elif allow_quit and cmd in ("quit", "q", "exit"): - menu.close_menu() - if cmd_on_quit is not None: - caller.execute_cmd(cmd_on_quit) - elif default: - goto, callback = default - if callback: - menu.callback(callback, raw_string) - if goto: - menu.goto(goto, raw_string) - else: - caller.msg(_HELP_NO_OPTION_MATCH) - - if not (options or default): - # no options - we are at the end of the menu. - menu.close_menu() - if cmd_on_quit is not None: - caller.execute_cmd(cmd_on_quit) + menu.parse_input(self.raw_string) class EvMenuCmdSet(CmdSet): @@ -269,7 +235,9 @@ class EvMenu(object): """ def __init__(self, caller, menudata, startnode="start", cmdset_mergetype="Replace", cmdset_priority=1, - allow_quit=True, cmd_on_quit="look"): + allow_quit=True, cmd_on_quit="look", + nodetext_formatter=None, options_formatter=None, + node_formatter=None): """ Initialize the menu tree and start the caller onto the first node. @@ -299,11 +267,35 @@ class EvMenu(object): allow_quit (bool, optional): Allow user to use quit or exit to leave the menu at any point. Recommended during development! - cmd_on_quit (str or None, optional): When exiting the menu + cmd_on_quit (callable, str or None, optional): When exiting the menu (either by reaching a node with no options or by using the in-built quit command (activated with `allow_quit`), this - command string will be executed. Set to None to not call - any command. + callback function or command string will be executed. + The callback function takes two parameters, the caller then the + EvMenu object. This is called after cleanup is complete. + Set to None to not call any command. + nodetext_formatter (callable, optional): This callable should be on + the form `function(nodetext, has_options)`, where `nodetext` is the + node text string and `has_options` a boolean specifying if there + are options associated with this node. It must return a formatted + string. + options_formatter (callable, optional): This callable should be on + the form `function(optionlist)`, where ` optionlist is a list + of option dictionaries, like + [{"key":..., "desc",..., "goto": ..., "exec",...}, ...] + Each dictionary describes each possible option. Note that this + will also be called if there are no options, and so should be + able to handle an empty list. This should + be formatted into an options list and returned as a string, + including the required separator to use between the node text + and the options. If not given the default EvMenu style will be used. + node_formatter (callable, optional): This callable should be on the + form `func(nodetext, optionstext)` where the arguments are strings + representing the node text and options respectively (possibly prepared + by `nodetext_formatter`/`options_formatter` or by the default styles). + It should return a string representing the final look of the node. This + can e.g. be used to create line separators that take into account the + dynamic width of the parts. Raises: EvMenuError: If the start/end node is not found in menu tree. @@ -313,17 +305,27 @@ class EvMenu(object): self._startnode = startnode self._menutree = self._parse_menudata(menudata) + self._nodetext_formatter = nodetext_formatter + self._options_formatter = nodetext_formatter + self._node_formatter = node_formatter + if startnode not in self._menutree: raise EvMenuError("Start node '%s' not in menu tree!" % startnode) # variables made available to the command self.allow_quit = allow_quit - self.cmd_on_quit = cmd_on_quit + if isinstance(cmd_on_quit, str): + self.cmd_on_quit = lambda caller, menu: caller.execute_cmd(cmd_on_quit) + elif callable(cmd_on_quit): + self.cmd_on_quit = cmd_on_quit + else: + self.cmd_on_quit = None self.default = None self.nodetext = None self.helptext = None self.options = None + # store ourself on the object self._caller.ndb._menutree = self @@ -382,74 +384,85 @@ class EvMenu(object): # handle the node text # - nodetext = dedent(nodetext).strip() + if self._nodetext_formatter: + # use custom formatter + nodetext = self._nodetext_formatter(nodetext, len(optionlist)) + else: + nodetext = dedent(nodetext).strip() nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) - if not optionlist: - # return the node text "naked". - separator1 = "_" * nodetext_width_max + "\n\n" if nodetext_width_max else "" - separator2 = "\n" if nodetext_width_max else "" + "_" * nodetext_width_max - return separator1 + nodetext + separator2 - # # handle the options # - # column separation distance - colsep = 4 + if self._options_formatter: + # use custom formatter + optionstext = self._options_formatter(optionlist) + elif optionlist: + # column separation distance + colsep = 4 - nlist = len(optionlist) + nlist = len(optionlist) - # get the widest option line in the table. - table_width_max = -1 - table = [] - for key, desc in optionlist: - table_width_max = max(table_width_max, - max(m_len(p) for p in key.split("\n")) + - max(m_len(p) for p in desc.split("\n")) + colsep) - raw_key = strip_ansi(key) - if raw_key != key: - # already decorations in key definition - table.append(ANSIString(" {lc%s{lt%s{le: %s" % (raw_key, key, desc))) - else: - # add a default white color to key - table.append(ANSIString(" {lc%s{lt{w%s{n{le: %s" % (raw_key, raw_key, desc))) + # get the widest option line in the table. + table_width_max = -1 + table = [] + for key, desc in optionlist: + table_width_max = max(table_width_max, + max(m_len(p) for p in key.split("\n")) + + max(m_len(p) for p in desc.split("\n")) + colsep) + raw_key = strip_ansi(key) + if raw_key != key: + # already decorations in key definition + table.append(ANSIString(" {lc%s{lt%s{le: %s" % (raw_key, key, desc))) + else: + # add a default white color to key + table.append(ANSIString(" {lc%s{lt{w%s{n{le: %s" % (raw_key, raw_key, desc))) - ncols = (_MAX_TEXT_WIDTH // table_width_max) + 1 # number of ncols - nlastcol = nlist % ncols # number of elements left in last row + ncols = (_MAX_TEXT_WIDTH // table_width_max) + 1 # number of ncols + nlastcol = nlist % ncols # number of elements left in last row - # get the amount of rows needed (start with 4 rows) - nrows = 4 - while nrows * ncols < nlist: - nrows += 1 - ncols = nlist // nrows # number of full columns - nlastcol = nlist % nrows # number of elements in last column + # get the amount of rows needed (start with 4 rows) + nrows = 4 + while nrows * ncols < nlist: + nrows += 1 + ncols = nlist // nrows # number of full columns + nlastcol = nlist % nrows # number of elements in last column - # get the final column count - ncols = ncols + 1 if nlastcol > 0 else ncols - if ncols > 1: - # only extend if longer than one column - table.extend([" " for i in xrange(nrows-nlastcol)]) + # get the final column count + ncols = ncols + 1 if nlastcol > 0 else ncols + if ncols > 1: + # only extend if longer than one column + table.extend([" " for i in xrange(nrows-nlastcol)]) - # build the actual table grid - table = [table[icol*nrows:(icol*nrows) + nrows] for icol in xrange(0, ncols)] + # build the actual table grid + table = [table[icol*nrows:(icol*nrows) + nrows] for icol in xrange(0, ncols)] - # adjust the width of each column - total_width = 0 - for icol in xrange(len(table)): - col_width = max(max(m_len(p) for p in part.split("\n")) for part in table[icol]) + colsep - table[icol] = [pad(part, width=col_width + colsep, align="l") for part in table[icol]] - total_width += col_width + # adjust the width of each column + for icol in xrange(len(table)): + col_width = max(max(m_len(p) for p in part.split("\n")) for part in table[icol]) + colsep + table[icol] = [pad(part, width=col_width + colsep, align="l") for part in table[icol]] - # format the table into columns - table = EvTable(table=table, border="none") + # format the table into columns + optionstext = unicode(EvTable(table=table, border="none")) + else: + optionstext = "" - # build the page - total_width = max(total_width, nodetext_width_max) - separator1 = "_" * total_width + "\n\n" if nodetext_width_max else "" - separator2 = "\n" + "_" * total_width + "\n\n" if total_width else "" - return separator1 + nodetext + separator2 + unicode(table) + options_width_max = max(m_len(line) for line in optionstext.split("\n")) + + # + # format the entire node + # + if self._node_formatter: + # use custom formatter + return self._node_formatter(nodetext, optionstext) + else: + # build the page + total_width = max(options_width_max, nodetext_width_max) + separator1 = "_" * total_width + "\n\n" if nodetext_width_max else "" + separator2 = "\n" + "_" * total_width + "\n\n" if total_width else "" + return separator1 + nodetext + separator2 + optionstext def _execute_node(self, nodename, raw_string): """ @@ -488,6 +501,56 @@ class EvMenu(object): return nodetext, options + def _display_nodetext(self): + self._caller.msg(self.nodetext) + + + def _display_helptext(self): + self._caller.msg(self.helptext) + + + def _callback_goto(self, callback, goto, raw_string): + if callback: + self.callback(callback, raw_string) + if goto: + self.goto(goto, raw_string) + + + def parse_input(self, raw_string): + """ + Processes the user' node inputs. + + Args: + raw_string (str): The incoming raw_string from the menu + command. + """ + + caller = self._caller + cmd = raw_string.strip().lower() + allow_quit = self.allow_quit + + if cmd in self.options: + # this will take precedence over the default commands + # below + goto, callback = self.options[cmd] + self._callback_goto(callback, goto, raw_string) + elif cmd in ("look", "l"): + self._display_nodetext() + elif cmd in ("help", "h"): + self._display_helptext() + elif allow_quit and cmd in ("quit", "q", "exit"): + self.close_menu() + elif self.default: + goto, callback = self.default + self._callback_goto(callback, goto, raw_string) + else: + caller.msg(_HELP_NO_OPTION_MATCH) + + if not (self.options or self.default): + # no options - we are at the end of the menu. + self.close_menu() + + def callback(self, nodename, raw_string): """ Run a node as a callback. This makes no use of the return @@ -582,7 +645,7 @@ class EvMenu(object): else: self.helptext = _HELP_NO_OPTIONS if self.allow_quit else _HELP_NO_OPTIONS_NO_QUIT - self._caller.execute_cmd("look") + self._display_nodetext() def close_menu(self): """ @@ -590,6 +653,8 @@ class EvMenu(object): """ self._caller.cmdset.remove(EvMenuCmdSet) del self._caller.ndb._menutree + if self.cmd_on_quit is not None: + self.cmd_on_quit(self._caller, self) # ------------------------------------------------------------------------------------------------- @@ -689,6 +754,9 @@ def test_start_node(caller): "desc": "Set an attribute on yourself.", "exec": lambda caller: caller.attributes.add("menuattrtest", "Test value"), "goto": "test_set_node"}, + {"key": ("{yL{nook", "l"), + "desc": "Look and see a custom message.", + "goto": "test_look_node"}, {"key": ("{yV{niew", "v"), "desc": "View your own name", "goto": "test_view_node"}, @@ -700,6 +768,13 @@ def test_start_node(caller): return text, options +def test_look_node(caller): + text = "Looking again will take you back to the previous message." + options = {"key": ("{yL{nook", "l"), + "desc": "Go back to the previous menu.", + "goto": "test_start_node"} + return text, options + def test_set_node(caller): text = (""" The attribute 'menuattrtest' was set to diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 869f2c1a4..8ee8e2340 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1537,7 +1537,7 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): error = "" if not matches: # no results. - error = kwargs.get("nofound_string", _("Could not find '%s'." % query)) + error = kwargs.get("nofound_string") or _("Could not find '%s'." % query) matches = None elif len(matches) > 1: error = kwargs.get("multimatch_string", None) diff --git a/requirements.txt b/requirements.txt index 7a552253f..889423270 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,5 @@ django >= 1.8, < 1.9 twisted >= 15.2.1 mock >= 1.0.1 -pillow +pillow == 2.9.0 pytz diff --git a/win_requirements.txt b/win_requirements.txt index 82d1b2c23..787d64367 100644 --- a/win_requirements.txt +++ b/win_requirements.txt @@ -6,5 +6,5 @@ pypiwin32 django >= 1.8, < 1.9 twisted >= 15.2.1 mock >= 1.0.1 -pillow +pillow == 2.9.0 pytz