diff --git a/contrib/README b/contrib/README new file mode 100644 index 0000000000..fa8fc77795 --- /dev/null +++ b/contrib/README @@ -0,0 +1,34 @@ + +'Contrib' folder +---------------- + +This folder contains 'contributions': extra snippets of code that are +potentially very useful for the game coder but which are considered +too game-specific to be a part of the main Evennia game server. These +modules are not used unless you explicitly import them. + +If you want to edit, tweak or expand on this code you should copy the +things you want from here into game/gamesrc and change them there. + +* Evennia MenuSystem (Griatch 2011) - A base set of classes and + cmdsets for creating in-game multiple-choice menus in + Evennia. The menu tree can be of any depth. Menu options can be + numbered or given custom keys, and each option can execute + code. Also contains a yes/no question generator function. This + is intended to be used by commands and presents a y/n question + to the user for accepting an action. Includes a simple new + command 'menu' for testing and debugging. + +* Evennia Lineeditor (Griatch 2011) - A powerful line-by-line editor + for editing text in-game. Mimics the command names of the famous + VI text editor. Supports undo/redo, search/replace, + regex-searches, buffer formatting, indenting etc. It comes with + its own help system. (Makes minute use of the MenuSystem module + to show a y/n question if quitting without having + saved). Includes a basic command '@edit' for activating the + editor. + +* Talking_NPC (Griatch 2011) - An example of a simple NPC object that + you can strike up a menu-driven converstaion with. Uses the + MenuSystem to allow conversation options. The npc object defines + a command 'talk' for starting the (brief) conversation. diff --git a/contrib/__init__.py b/contrib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/lineeditor.py b/contrib/lineeditor.py new file mode 100644 index 0000000000..c488759db4 --- /dev/null +++ b/contrib/lineeditor.py @@ -0,0 +1,609 @@ +""" + +Evennia Line Editor + +Contribution - Griatch 2011 + +This implements an advanced line editor for editing longer texts +in-game. The editor mimics the command mechanisms of the VI editor as +far as possible. + +Features of the editor: + undo/redo + edit/replace on any line of the buffer + search&replace text anywhere in buffer + formatting of buffer, or selection, to certain width + indentations + allow to echo the input or not depending on your client. +""" + +import re +from src.commands.command import Command +from src.commands.cmdset import CmdSet +from src.commands.cmdhandler import CMD_NOMATCH, CMD_NOINPUT +from src.utils import utils +from contrib.menusystem import prompt_yesno + +RE_GROUP = re.compile(r"\".*?\"|\'.*?\'|\S*") + +class CmdEditorBase(Command): + """ + Base parent for editor commands + """ + locks = "cmd:all()" + help_entry = "LineEditor" + + code = None + editor = None + + def parse(self): + """ + Handles pre-parsing + + Editor commands are on the form + :cmd [li] [w] [txt] + + Where all arguments are optional. + li - line number (int), starting from 1. This could also be a range given as : + w - word(s) (string), could be encased in quotes. + txt - extra text (string), could be encased in quotes + """ + + linebuffer = [] + if self.editor: + linebuffer = self.editor.buffer.split("\n") + nlines = len(linebuffer) + + # The regular expression will split the line by whitespaces, + # stripping extra whitespaces, except if the text is + # surrounded by single- or double quotes, in which case they + # will be kept together and extra whitespace preserved. You + # can input quotes on the line by alternating single and + # double quotes. + arglist = [part for part in RE_GROUP.findall(self.args) if part] + temp = [] + for arg in arglist: + # we want to clean the quotes, but only one type, in case we are nesting. + if arg.startswith('"'): + arg.strip('"') + elif arg.startswith("'"): + arg.strip("'") + temp.append(arg) + arglist = temp + + + # A dumb split, without grouping quotes + words = self.args.split() + + # current line number + cline = nlines - 1 + + # the first argument could also be a range of line numbers, on the + # form :. Either of the ends could be missing, to + # mean start/end of buffer respectively. + + lstart, lend = cline, cline + 1 + linerange = False + if arglist and ':' in arglist[0]: + part1, part2 = arglist[0].split(':') + if part1 and part1.isdigit(): + lstart = min(max(0, int(part1)) - 1, nlines) + linerange = True + if part2 and part2.isdigit(): + lend = min(lstart + 1, int(part2)) + 1 + linerange = True + elif arglist and arglist[0].isdigit(): + lstart = min(max(0, int(arglist[0]) - 1), nlines) + lend = lstart + 1 + linerange = True + if linerange: + arglist = arglist[1:] + + # nicer output formatting of the line range. + lstr = "" + if not linerange or lstart + 1 == lend: + lstr = "line %i" % (lstart + 1) + else: + lstr = "lines %i-%i" % (lstart + 1, lend) + + + # arg1 and arg2 is whatever arguments. Line numbers or -ranges are never included here. + args = " ".join(arglist) + arg1, arg2 = "", "" + if len(arglist) > 1: + arg1, arg2 = arglist[0], " ".join(arglist[1:]) + else: + arg1 = " ".join(arglist) + + # store for use in func() + + self.linebuffer = linebuffer + self.nlines = nlines + self.arglist = arglist + self.cline = cline + self.lstart = lstart + self.lend = lend + self.linerange = linerange + self.lstr = lstr + self.words = words + self.args = args + self.arg1 = arg1 + self.arg2 = arg2 + + def func(self): + "Implements the Editor commands" + pass + + +class CmdLineInput(CmdEditorBase): + """ + No command match - Inputs line of text into buffer. + """ + key = CMD_NOMATCH + aliases = [CMD_NOINPUT] + def func(self): + "Adds the line without any formatting changes." + # add a line of text + if not self.editor.buffer: + buf = self.args + else: + buf = self.editor.buffer + "\n%s" % self.args + self.editor.update_buffer(buf) + if self.editor.echo_mode: + self.caller.msg("%02i| %s" % (self.cline + 1, self.args)) + +class CmdEditorGroup(CmdEditorBase): + """ + Commands for the editor + """ + key = ":editor_command_group" + aliases = [":","::", ":::", ":h", ":w", ":wq", ":q", ":q!", ":u", ":uu", ":UU", + ":dd", ":dw", ":DD", ":y", ":x", ":p", ":i", + ":r", ":I", ":A", ":s", ":S", ":f", ":fi", ":fd", ":echo"] + + def func(self): + """ + This command handles all the in-editor :-style commands. Since each command + is small and very limited, this makes for a more efficient presentation. + """ + caller = self.caller + editor = self.editor + linebuffer = self.linebuffer + lstart, lend = self.lstart, self.lend + cmd = self.cmdstring + echo_mode = self.editor.echo_mode + string = "" + + if cmd == ":": + # Echo buffer + if self.linerange: + buf = linebuffer[lstart:lend] + string = editor.display_buffer(buf=buf, offset=lstart) + else: + string = editor.display_buffer() + elif cmd == "::": + # Echo buffer without the line numbers and syntax parsing + if self.linerange: + buf = linebuffer[lstart:lend] + string = editor.display_buffer(buf=buf, offset=lstart, linenums=False) + else: + string = editor.display_buffer(linenums=False) + self.caller.msg(string, data={"raw":True}) + return + elif cmd == ":::": + # Insert single colon alone on a line + editor.update_buffer(editor.buffer + "\n:") + if echo_mode: + string = "Single ':' added to buffer." + elif cmd == ":h": + # help entry + string = editor.display_help() + elif cmd == ":w": + # save without quitting + string = editor.save_buffer() + elif cmd == ":wq": + # save and quit + string = editor.save_buffer() + string += " " + editor.quit() + elif cmd == ":q": + # quit. If not saved, will ask + if self.editor.unsaved: + prompt_yesno(caller, "Save before quitting?", + yescode = "self.caller.ndb._lineeditor.save_buffer()\nself.caller.ndb._lineeditor.quit()", + nocode = "self.caller.msg(self.caller.ndb._lineeditor.quit())", default="Y") + else: + string = editor.quit() + elif cmd == ":q!": + # force quit, not checking saving + string = editor.quit() + elif cmd == ":u": + # undo + string = editor.update_undo(-1) + elif cmd == ":uu": + # redo + string = editor.update_undo(1) + elif cmd == ":UU": + # reset buffer + editor.update_buffer(editor.pristine_buffer) + string = "Reverted all changes to the buffer back to original state." + elif cmd == ":dd": + # :dd - delete line + buf = linebuffer[:lstart] + linebuffer[lend:] + editor.update_buffer(buf) + string = "Deleted %s." % (self.lstr) + elif cmd == ":dw": + # :dw - delete word in entire buffer + # :dw delete word only on line(s) + if not self.arg1: + string = "You must give a search word to delete." + else: + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + string = "Removed %s for lines %i-%i." % (self.arg1, lstart + 1 , lend + 1) + else: + string = "Removed %s for %s." % (self.arg1, self.lstr) + sarea = "\n".join(linebuffer[lstart:lend]) + sarea = re.sub(r"%s" % self.arg1.strip("\'").strip('\"'), "", sarea, re.MULTILINE) + buf = linebuffer[:lstart] + sarea.split("\n") + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":DD": + # clear buffer + editor.update_buffer("") + string = "Cleared %i lines from buffer." % self.nlines + elif cmd == ":y": + # :y - yank line(s) to copy buffer + cbuf = linebuffer[lstart:lend] + editor.copy_buffer = cbuf + string = "%s, %s yanked." % (self.lstr.capitalize(), cbuf) + elif cmd == ":x": + # :x - cut line to copy buffer + cbuf = linebuffer[lstart:lend] + editor.copy_buffer = cbuf + buf = linebuffer[:lstart] + linebuffer[lend:] + editor.update_buffer(buf) + string = "%s, %s cut." % (self.lstr.capitalize(), cbuf) + elif cmd == ":p": + # :p paste line(s) from copy buffer + if not editor.copy_buffer: + string = "Copy buffer is empty." + else: + buf = linebuffer[:lstart] + editor.copy_buffer + linebuffer[lstart:] + editor.update_buffer(buf) + string = "Copied buffer %s to %s." % (editor.copy_buffer, self.lstr) + elif cmd == ":i": + # :i - insert new line + new_lines = self.args.split('\n') + if not new_lines: + string = "You need to enter a new line and where to insert it." + else: + buf = linebuffer[:lstart] + new_lines + linebuffer[lstart:] + editor.update_buffer(buf) + string = "Inserted %i new line(s) at %s." % (len(new_lines), self.lstr) + elif cmd == ":r": + # :r - replace lines + new_lines = self.args.split('\n') + if not new_lines: + string = "You need to enter a replacement string." + else: + buf = linebuffer[:lstart] + new_lines + linebuffer[lend:] + editor.update_buffer(buf) + string = "Replaced %i line(s) at %s." % (len(new_lines), self.lstr) + elif cmd == ":I": + # :I - insert text at beginning of line(s) + if not self.args: + string = "You need to enter text to insert." + else: + buf = linebuffer[:lstart] + ["%s%s" % (self.args, line) for line in linebuffer[lstart:lend]] + linebuffer[lend:] + editor.update_buffer(buf) + string = "Inserted text at beginning of %s." % self.lstr + elif cmd == ":A": + # :A - append text after end of line(s) + if not self.args: + string = "You need to enter text to append." + else: + buf = linebuffer[:lstart] + ["%s%s" % (line, self.args) for line in linebuffer[lstart:lend]] + linebuffer[lend:] + editor.update_buffer(buf) + string = "Appended text to end of %s." % self.lstr + elif cmd == ":s": + # :s
  • - search and replace words in entire buffer or on certain lines + if not self.arg1 or not self.arg2: + string = "You must give a search word and something to replace it with." + else: + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + string = "Search-replaced %s -> %s for lines %i-%i." % (self.arg1, self.arg2, lstart + 1 , lend) + else: + string = "Search-replaced %s -> %s for %s." % (self.arg1, self.arg2, self.lstr) + sarea = "\n".join(linebuffer[lstart:lend]) + + regex = r"%s|^%s(?=\s)|(?<=\s)%s(?=\s)|^%s$|(?<=\s)%s$" + regarg = self.arg1.strip("\'").strip('\"') + if " " in regarg: + regarg = regarg.replace(" ", " +") + sarea = re.sub(regex % (regarg, regarg, regarg, regarg, regarg), self.arg2.strip("\'").strip('\"'), sarea, re.MULTILINE) + buf = linebuffer[:lstart] + sarea.split("\n") + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":f": + # :f flood-fill buffer or lines of buffer. + width = 78 + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + string = "Flood filled lines %i-%i." % (lstart + 1 , lend) + else: + string = "Flood filled %s." % self.lstr + fbuf = "\n".join(linebuffer[lstart:lend]) + fbuf = utils.fill(fbuf, width=width) + buf = linebuffer[:lstart] + fbuf.split("\n") + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":fi": + # :fi indent buffer or lines of buffer. + indent = " " * 4 + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + string = "Indented lines %i-%i." % (lstart + 1 , lend) + else: + string = "Indented %s." % self.lstr + fbuf = [indent + line for line in linebuffer[lstart:lend]] + buf = linebuffer[:lstart] + fbuf + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":fd": + # :fi indent buffer or lines of buffer. + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + string = "Removed left margin (dedented) lines %i-%i." % (lstart + 1 , lend) + else: + string = "Removed left margin (dedented) %s." % self.lstr + fbuf = "\n".join(linebuffer[lstart:lend]) + fbuf = utils.dedent(fbuf) + buf = linebuffer[:lstart] + fbuf.split("\n") + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":echo": + # set echoing on/off + editor.echo_mode = not editor.echo_mode + string = "Echo mode set to %s" % editor.echo_mode + caller.msg(string) + + +class EditorCmdSet(CmdSet): + "CmdSet for the editor commands" + key = "editorcmdset" + mergetype = "Replace" + +class LineEditor(object): + """ + This defines a line editor object. It creates all relevant commands + and tracks the current state of the buffer. It also cleans up after + itself. + """ + + def __init__(self, caller, loadcode="", savecode="", key=""): + """ + caller - who is using the editor + loadcode - code to execute in order to load already existing text into the buffer + savecode - code to execute in order to save the result + key = an optional key for naming this session (such as which attribute is being edited) + """ + self.key = key + self.caller = caller + self.caller.ndb._lineeditor = self + self.buffer = "" + self.unsaved = False + if loadcode: + try: + exec(loadcode) + except Exception, e: + caller.msg("%s\n{rBuffer loadcode failed. Could not load initial data.{n" % e) + + # Create the commands we need + cmd1 = CmdLineInput() + cmd1.editor = self + cmd1.obj = self + cmd2 = CmdEditorGroup() + cmd2.obj = self + cmd2.editor = self + # Populate cmdset and add it to caller + editor_cmdset = EditorCmdSet() + editor_cmdset.add(cmd1) + editor_cmdset.add(cmd2) + self.caller.cmdset.add(editor_cmdset) + + # store the original version + self.pristine_buffer = self.buffer + + self.savecode = savecode + self.sep = "-" + + # undo operation buffer + self.undo_buffer = [self.buffer] + self.undo_pos = 0 + self.undo_max = 20 + + # copy buffer + self.copy_buffer = [] + + # echo inserted text back to caller + self.echo_mode = False + + # show the buffer ui + self.caller.msg(self.display_buffer()) + + def update_buffer(self, buf): + """ + This should be called when the buffer has been changed somehow. + It will handle unsaved flag and undo updating. + """ + if utils.is_iter(buf): + buf = "\n".join(buf) + + if buf != self.buffer: + self.buffer = buf + self.update_undo() + self.unsaved = True + + def quit(self): + "Cleanly exit the editor." + del self.caller.ndb._lineeditor + self.caller.cmdset.delete(EditorCmdSet) + return "Exited editor." + + def save_buffer(self): + "Saves the content of the buffer" + if self.unsaved: + try: + exec(self.savecode) + self.unsaved = False + return "Buffer saved." + except Exception, e: + return "%s\n{rSave code gave an error. Buffer not saved." % e + else: + return "No changes need saving." + + def update_undo(self, step=None): + """ + This updates the undo position. + + """ + if step and step < 0: + if self.undo_pos <= 0: + return "Nothing to undo." + self.undo_pos = max(0, self.undo_pos + step) + self.buffer = self.undo_buffer[self.undo_pos] + return "Undo." + elif step and step > 0: + if self.undo_pos >= len(self.undo_buffer) - 1 or self.undo_pos + 1 >= self.undo_max: + return "Nothing to redo." + self.undo_pos = min(self.undo_pos + step, min(len(self.undo_buffer), self.undo_max) - 1) + self.buffer = self.undo_buffer[self.undo_pos] + return "Redo." + if not self.undo_buffer or (self.undo_buffer and self.buffer != self.undo_buffer[self.undo_pos]): + self.undo_buffer = self.undo_buffer[:self.undo_pos + 1] + [self.buffer] + self.undo_pos = len(self.undo_buffer) - 1 + + def display_buffer(self, buf=None, offset=0, linenums=True): + """ + This displays the line editor buffer, or selected parts of it. + + If buf is set and is not the full buffer, offset should define + the starting line number, to get the linenum display right. + """ + if buf == None: + buf = self.buffer + if utils.is_iter(buf): + buf = "\n".join(buf) + + lines = buf.split('\n') + nlines = len(lines) + nwords = len(buf.split()) + nchars = len(buf) + + sep = self.sep + header = "{n" + sep * 10 + "Line Editor [%s]" % self.key + sep * (78-25-len(self.key)) + footer = "{n" + sep * 10 + "[l:%02i w:%03i c:%04i]" % (nlines, nwords, nchars) + sep * 12 + "(:h for help)" + sep * 23 + if linenums: + main = "\n".join("{b%02i|{n %s" % (iline + 1 + offset, line) for iline, line in enumerate(lines)) + else: + main = "\n".join(lines) + string = "%s\n%s\n%s" % (header, main, footer) + return string + + def display_help(self): + """ + Shows the help entry for the editor. + """ + string = self.sep*78 + """ + - any non-command is appended to the end of the buffer. +: - view buffer or only line +:: - view buffer without line numbers or other parsing +::: - print a ':' as the only character on the line... +:h - this help. + +:w - saves the buffer (don't quit) +:wq - save buffer and quit +:q - quits (will be asked to save if buffer was changed) +:q! - quit without saving, no questions asked + +:u - (undo) step backwards in undo history +:uu - (redo) step forward in undo history +:UU - reset all changes back to initial + +:dd - delete line +:dw - delete word or regex in entire buffer or on line +:DD - clear buffer + +:y - yank (copy) line to the copy buffer +:x - cut line and store it in the copy buffer +:p - put (paste) previously copied line directly after +:i - insert new text at line . Old line will be shifted down +:r - replace line with text +:I - insert text at the beginning of line +:A - append text after the end of line + +:s - search/replace word or regex in buffer or on line + +:f - flood-fill entire buffer or line +:fi - indent entire buffer or line +:fd - de-indent entire buffer or line + +:echo - turn echoing of the input on/off (helpful for some clients) + + Legend: + - line numbers, or range lstart:lend, e.g. '3:7'. + - one word or several enclosed in quotes. + - longer string, usually not needed to be enclosed in quotes. +""" + self.sep * 78 + return string + + + + +# +# Editor access command for editing a given attribute on an object. +# + +class CmdEditor(Command): + """ + start editor + + Usage: + @editor / + + This will start Evennia's powerful line editor, which + has a host of commands on its own. Use :h for a list + of commands. + + """ + + key = "@editor" + aliases = ["@edit"] + locks = "cmd:perm(editor) or perm(Builders)" + help_category = "Building" + + def func(self): + "setup and start the editor" + + if not self.args or not '/' in self.args: + self.caller.msg("Usage: @editor /") + return + objname, attrname = [part.strip() for part in self.args.split("/")] + obj = self.caller.search(objname) + if not obj: + return + + # the load/save codes define what the editor shall do when wanting to + # save the result of the editing. The editor makes self.buffer and + # self.caller available for this code - self.buffer holds the editable text. + + loadcode = "obj = self.caller.search('%s')\n" % obj.id + loadcode += "if obj.db.%s: self.buffer = obj.db.%s" % (attrname, attrname) + + savecode = "obj = self.caller.search('%s')\n" % obj.id + savecode += "obj.db.%s = self.buffer" % attrname + + editor_key = "%s/%s" % (objname, attrname) + + # start editor, it will handle things from here. + LineEditor(self.caller, loadcode=loadcode, savecode=savecode, key=editor_key) diff --git a/contrib/menusystem.py b/contrib/menusystem.py new file mode 100644 index 0000000000..2d53fbbc06 --- /dev/null +++ b/contrib/menusystem.py @@ -0,0 +1,413 @@ +""" +Evennia menu system. + +Contribution - Griatch 2011 + +This module offers the ability for admins to let their game be fully +or partly menu-driven. Menu choices can be numbered or use arbitrary +keys. There are also some formatting options, such a putting options +in one or more collumns. + +The menu system consists of a MenuTree object populated by MenuNode +objects. Nodes are linked together with automatically created commands +so the player may select and traverse the menu. Each node can display +text and show options, but also execute arbitrary code to act on the +system and the calling object when they are selected. + +There is also a simple Yes/No function supplied. This will create a +one-off Yes/No question and executes a given code depending on which +choice was made. + +To test, import and add the CmdTestMenu command to the end of the default cmdset in +game.gamesrc.commands.basecmdset. The test command is also a good +example of how to use this module in code. + +""" +from src.commands.cmdhandler import CMD_NOMATCH, CMD_NOINPUT +from src.commands.command import Command +from src.commands.cmdset import CmdSet +from src.commands.default.general import CmdLook +from src.commands.default.help import CmdHelp +from src.utils import utils + +# imported only to make them available during execution of code blocks +from src.objects.models import ObjectDB +from src.players.models import PlayerDB + + +# +# Commands used by the Menu system +# + +class CmdMenuNode(Command): + """ + Parent for menu selection commands. + """ + key = "selection" + aliases = [] + locks = "cmd:all()" + help_category = "Menu" + + menutree = None + code = None + + def func(self): + "Execute a selection" + if self.code: + try: + exec(self.code) + except Exception, e: + self.caller.msg("%s\n{rThere was an error with this selection.{n" % e) + else: + self.caller.msg("{rThis option is not available.{n") + +class CmdMenuLook(CmdLook): + """ + ooc look + + Usage: + look + + This is a Menu version of the look command. It will normally show + the options available, otherwise works like the normal look + command.. + """ + key = "look" + aliases = ["l", "ls"] + locks = "cmd:all()" + help_cateogory = "General" + + def func(self): + "implement the menu look command" + if self.caller.db._menu_data: + # if we have menu data, try to use that. + lookstring = self.caller.db._menu_data.get("look", None) + if lookstring: + self.caller.msg(lookstring) + return + # otherwise we use normal look + super(CmdMenuLook, self).func() + +class CmdMenuHelp(CmdHelp): + """ + help + + Usage: + help + + Get help specific to the menu, if available. If not, + works like the normal help command. + """ + key = "help" + aliases = "h" + locks = "cmd:all()" + help_category = "Menu" + + def func(self): + "implement the menu help command" + if self.caller.db._menu_data: + # if we have menu data, try to use that. + lookstring = self.caller.db._menu_data.get("help", None) + if lookstring: + self.caller.msg(lookstring) + return + # otherwise we use normal help + super(CmdMenuHelp, self).func() + +class MenuCmdSet(CmdSet): + """ + Cmdset for the menu. Will replace all other commands. + This always has a few basic commands available. + + Note that you must always supply a way to exit the + cmdset manually! + """ + key = "menucmdset" + priority = 1 + mergetype = "Replace" + def at_cmdset_creation(self): + "populate cmdset" + self.add(CmdMenuLook()) + self.add(CmdMenuHelp()) + +# +# Menu Node system +# + +class MenuTree(object): + """ + The menu tree object holds the full menu structure consisting of + MenuNodes. Each node is identified by a unique key. The tree + allows for traversal of nodes as well as entering and exiting the + tree as needed. For safety, being in a menu will not survive a + server reboot. + + A menutree have two special node keys given by 'startnode' and + 'endnode' arguments. The startnode is where the user will start + upon first entering the menu. The endnode need not actually + exist, the moment it is linked to and that link is used, the menu + will be exited and cleanups run. The default keys for these are + 'START' and 'END' respectively. + + """ + def __init__(self, caller, nodes=None, startnode="START", endnode="END"): + """ + We specify startnode/endnode so that the system knows where to + enter and where to exit the menu tree. If nodes is given, it + shuld be a list of valid node objects to add to the tree. + """ + self.tree = {} + self.startnode = startnode + self.endnode = endnode + self.caller = caller + if nodes and utils.is_iter(nodes): + for node in nodes: + self.add(node) + + def start(self): + """ + Initialize the menu + """ + self.goto(self.startnode) + + def add(self, menunode): + """ + Add a menu node object to the tree. Each node itself keeps + track of which nodes it is connected to. + """ + menunode.init(self) + self.tree[menunode.key] = menunode + + def goto(self, key): + """ + Go to a key in the tree. This sets up the cmdsets on the + caller so that they match the choices in that node. + """ + if key == self.endnode: + # if we was given the END node key, we clean up immediately. + self.caller.cmdset.delete("menucmdset") + del self.caller.db._menu_data + self.caller.execute_cmd("look") + return + # not exiting, look for a valid code. + node = self.tree.get(key, None) + if node: + if node.code: + # Execute eventual code active on this + # node. self.caller is available at this point. + try: + exec(node.code) + except Exception, e: + self.caller.msg("{rCode could not be executed for node %s. Continuing anyway.{n" % key) + # clean old menu cmdset and replace with the new one + self.caller.cmdset.delete("menucmdset") + self.caller.cmdset.add(node.cmdset) + # set the menu flag data for the default commands + self.caller.db._menu_data = {"help":node.helptext, "look":str(node.text)} + # display the node + self.caller.msg(node.text) + else: + self.caller.msg("{rMenu node '%s' does not exist - maybe it's not created yet..{n" % key) + + +class MenuNode(object): + """ + This represents a node in a menu tree. The node will display its + textual content and offer menu links to other nodes (the relevant + commands are created automatically) + + """ + def __init__(self, key, text="", links=None, linktexts=None, + keywords=None, cols=1, helptext=None, code=""): + """ + key - the unique identifier of this node. + text - is the text that will be displayed at top when viewing this node. + links - a list of keys for unique menunodes this is connected to. + linktexts - a list of texts to describe the links. If defined, need to match links list + keywords - a list of unique keys for choosing links. Must match links list. If not given, index numbers will be used. + cols - how many columns to use for displaying options. + helptext - if defined, this is shown when using the help command instead of the normal help index. + code - functional code. This will be executed just before this node is loaded (i.e. + as soon after it's been selected from another node). self.caller is available + to call from this code block, as well as ObjectDB and PlayerDB. + """ + self.key = key + self.cmdset = None + self.links = links + self.linktexts = linktexts + self.keywords = keywords + self.cols = cols + self.code = code + + # validate the input + if not self.links: + self.links = [] + if not self.linktexts or (self.linktexts and len(self.linktexts) != len(self.links)): + self.linktexts = [] + if not self.keywords or (self.keywords and len(self.keywords) != len(self.links)): + self.keywords = [] + + # Format default text for the menu-help command + if not helptext: + helptext = "Select one of the valid options" + if self.keywords: + helptext += " (" + ", ".join(self.keywords) + ")" + elif self.links: + helptext += " (" + ", ".join([str(i + 1) for i in range(len(self.links))]) + ")" + self.helptext = helptext + + # Format text display + string = "" + if text: + string += "%s\n" % text + + # format the choices into as many collumns as specified + choices = [] + for ilink, link in enumerate(self.links): + if self.keywords: + choice = "{g%s{n" % self.keywords[ilink] + else: + choice = "{g%i{n" % (ilink + 1) + if self.linktexts: + choice += "-%s" % self.linktexts[ilink] + choices.append(choice) + cols = [[] for i in range(min(len(choices), cols))] + while True: + for i in range(len(cols)): + if not choices: + cols[i].append("") + else: + cols[i].append(choices.pop(0)) + if not choices: + break + ftable = utils.format_table(cols) + for row in ftable: + string += "\n" + "".join(row) + # store text + self.text = 78*"-" + "\n" + string.strip() + + def init(self, menutree): + """ + Called by menu tree. Initializes the commands needed by the menutree structure. + """ + # Create the relevant cmdset + self.cmdset = MenuCmdSet() + for i, link in enumerate(self.links): + cmd = CmdMenuNode() + cmd.key = str(i + 1) + cmd.menutree = menutree + # this is the operable command, it moves us to the next node. + cmd.code = "self.menutree.goto('%s')" % link + if self.keywords: + cmd.aliases = [self.keywords[i]] + self.cmdset.add(cmd) + + def __str__(self): + "Returns the string representation." + return self.text + + +# +# A simple yes/no question. Call this from a command to give object +# a cmdset where they may say yes or no to a question. Does not +# make use the node system since there is only one level of choice. +# + +def prompt_yesno(caller, question="", yescode="", nocode="", default="N"): + """ + This sets up a simple yes/no questionnaire. Question will + be asked, followed by a Y/[N] prompt where the [x] signifies + the default selection. + """ + + # creating and defining commands + cmdyes = CmdMenuNode() + cmdyes.key = "yes" + cmdyes.aliases = ["y"] + # this will be executed in the context of the yes command (so self.caller will be available) + cmdyes.code = yescode + "\nself.caller.cmdset.delete('menucmdset')\ndel self.caller.db._menu_data" + + cmdno = CmdMenuNode() + cmdno.key = "no" + cmdno.aliases = ["n"] + # this will be executed in the context of the no command + cmdno.code = nocode + "\nself.caller.cmdset.delete('menucmdset')\ndel self.caller.db._menu_data" + + errorcmd = CmdMenuNode() + errorcmd.key = CMD_NOMATCH + errorcmd.code = "self.caller.msg('Please choose either Yes or No.')" + + defaultcmd = CmdMenuNode() + defaultcmd.key = CMD_NOINPUT + defaultcmd.code = "self.caller.execute_cmd('%s')" % default + + # creating cmdset (this will already have look/help commands) + yesnocmdset = MenuCmdSet() + yesnocmdset.add(cmdyes) + yesnocmdset.add(cmdno) + yesnocmdset.add(errorcmd) + yesnocmdset.add(defaultcmd) + + # assinging menu data flags to caller. + caller.db._menu_data = {"help":"Please select Yes or No.", + "look":"Please select Yes or No."} + # assign cmdset and ask question + caller.cmdset.add(yesnocmdset) + if default == "Y": + prompt = "[Y]/N" + else: + prompt = "Y/[N]" + prompt = "%s %s: " % (question, prompt) + caller.msg(prompt) + +# +# Menu command test +# + +class CmdMenuTest(Command): + """ + testing menu module + + Usage: + menu + menu yesno + + This will test the menu system. The normal operation will produce + a small menu tree you can move around in. The 'yesno' option will + instead show a one-time yes/no question. + + """ + + key = "menu" + locks = "cmd:all()" + help_category = "Menu" + def func(self): + "Testing the menu system" + + if not self.args or self.args != "yesno": + # testing the full menu-tree system + + node0 = MenuNode("START", text="Start node. Select one of the links below. Here the links are ordered in one column.", + links=["node1", "node2", "END"], linktexts=["Goto first node", "Goto second node", "Quit"]) + node1 = MenuNode("node1", text="First node. This node shows letters instead of numbers for the choices.", + links=["END", "START"], linktexts=["Quit", "Back to start"], keywords=["q","b"]) + node2 = MenuNode("node2", text="Second node. This node lists choices in two columns.", + links=["node3", "START"], linktexts=["Set an attribute", "Back to start"], cols=2) + node3 = MenuNode("node3", text="Attribute 'menutest' set on you. You can examine it (only works if you are allowed to use the examine command) or remove it. You can also quit and examine it manually.", + links=["node4", "node5", "node2", "END"], linktexts=["Remove attribute", "Examine attribute", + "Back to second node", "Quit menu"], cols=2, + code="self.caller.db.menutest='Testing!'") + node4 = MenuNode("node4", text="Attribute 'menutest' removed again.", + links=["node2"], linktexts=["Back to second node."], cols=2, + code="del self.caller.db.menutest") + node5 = MenuNode("node5", links=["node4", "node2"], linktexts=["Remove attribute", "Back to second node."], cols=2, + code="self.caller.msg('%s/%s = %s' % (self.caller.key, 'menutest', self.caller.db.menutest))") + + menu = MenuTree(self.caller, nodes=(node0, node1, node2, node3, node4, node5)) + menu.start() + else: + "Testing the yesno question" + prompt_yesno(self.caller, question="Please answer yes or no - Are you the master of this mud or not?", + yescode="self.caller.msg('{gGood for you!{n')", + nocode="self.caller.msg('{GNow you are just being modest ...{n')", + default="N") diff --git a/contrib/talking_npc.py b/contrib/talking_npc.py new file mode 100644 index 0000000000..e0c7806157 --- /dev/null +++ b/contrib/talking_npc.py @@ -0,0 +1,123 @@ +""" + +Evennia Talkative NPC + +Contribution - Griatch 2011 + +This is a simple NPC object capable of holding a +simple menu-driven conversation. Create it by +creating an object of typeclass contrib.talking_npc.TalkingNPC, +For example using @create: + + @create John : contrib.talking_npc.TalkingNCP + +Walk up to it and give the talk command +to strike up a conversation. If there are many +talkative npcs in the same room you will get to +choose which one's talk command to call (Evennia +handles this automatically). + +Note that this is only a prototype class, showcasing +the uses of the menusystem module. It is NOT a full +mob implementation. + +""" + +from contrib import menusystem +from game.gamesrc.objects.baseobjects import Object +from game.gamesrc.commands.basecmdset import CmdSet +from game.gamesrc.commands.basecommand import MuxCommand + + +# +# The talk command +# + +class CmdTalk(MuxCommand): + """ + talks to an npc + + Usage: + talk + + This command is only available if a talkative non-player-character (NPC) + is actually present. It will strike up a conversation with that NPC + and give you options on what to talk about. + """ + key = "talk" + locks = "cmd:all()" + help_category = "General" + + def func(self): + "Implements the command." + + # self.obj is the NPC this is defined on + obj = self.obj + + self.caller.msg("(You walk up and talk to %s.)" % self.obj.key) + + # conversation is a dictionary of keys, each pointing to a dictionary defining + # the keyword arguments to the MenuNode constructor. + conversation = obj.db.conversation + if not conversation: + self.caller.msg("%s says: 'Sorry, I don't have time to talk right now.'" % (self.obj.key)) + return + + # build all nodes by loading them from the conversation tree. + menu = menusystem.MenuTree(self.caller) + for key, kwargs in conversation.items(): + menu.add(menusystem.MenuNode(key, **kwargs)) + menu.start() + +class TalkingCmdSet(CmdSet): + "Stores the talk command." + key = "talkingcmdset" + def at_cmdset_creation(self): + "populates the cmdset" + self.add(CmdTalk()) + +# +# Discussion tree. See contrib.menusystem.MenuNode for the keywords. +# (This could be in a separate module too) +# + +CONV = {"START":{"text": "Hello there, how can I help you?", + "links":["info1", "info2"], + "linktexts":["Hey, do you know what this 'Evennia' thing is all about?", + "What's your name, little NPC?"], + "keywords":None, + "code":None}, + "info1":{"text": "Oh, Evennia is where you are right now! Don't you feel the power?", + "links":["info3", "info2", "END"], + "linktexts":["Sure, *I* do, not sure how you do though. You are just an NPC.", + "Sure I do. What's yer name, NPC?", + "Ok, bye for now then."], + "keywords":None, + "code":None}, + "info2":{"text":"My name is not really important ... I'm just an NPC after all.", + "links":["info3", "info1"], + "linktexts":["I didn't really want to know it anyhow.", + "Okay then, so what's this 'Evennia' thing about?"], + "keywords":None, + "code":None}, + "info3":{"text":"Well ... I'm sort of busy so, have to go. NPC business. Important stuff. You wouldn't understand.", + "links":["END", "info2"], + "linktexts":["Oookay ... I won't keep you. Bye.", + "Wait, why don't you tell me your name first?"], + "keywords":None, + "code":None}, + } + +class TalkingNPC(Object): + """ + This implements a simple Object using the talk command and using the + conversation defined above. . + """ + + def at_object_creation(self): + "This is called when object is first created." + # store the conversation. + self.db.conversation = CONV + self.db.desc = "This is a talkative NPC." + # assign the talk command to npc + self.cmdset.add_default(TalkingCmdSet, permanent=True) diff --git a/game/gamesrc/commands/basecmdset.py b/game/gamesrc/commands/basecmdset.py index 483c616ec0..224317442b 100644 --- a/game/gamesrc/commands/basecmdset.py +++ b/game/gamesrc/commands/basecmdset.py @@ -20,9 +20,10 @@ new cmdset class. from src.commands.cmdset import CmdSet from src.commands.default import cmdset_default, cmdset_unloggedin, cmdset_ooc - from game.gamesrc.commands.basecommand import Command +from contrib import menusystem, lineeditor + class DefaultCmdSet(cmdset_default.DefaultCmdSet): """ This is an example of how to overload the default command @@ -44,7 +45,8 @@ class DefaultCmdSet(cmdset_default.DefaultCmdSet): # # any commands you add below will overload the default ones. # - + #self.add(menusystem.CmdMenuTest()) + #self.add(lineeditor.CmdEditor()) class UnloggedinCmdSet(cmdset_unloggedin.UnloggedinCmdSet): """ diff --git a/src/commands/cmdhandler.py b/src/commands/cmdhandler.py index b723d90993..e4d929fa37 100644 --- a/src/commands/cmdhandler.py +++ b/src/commands/cmdhandler.py @@ -54,6 +54,7 @@ from django.conf import settings from src.comms.channelhandler import CHANNELHANDLER from src.commands.cmdsethandler import import_cmdset from src.utils import logger, utils +from src.commands.cmdparser import at_multimatch_cmd #This switches the command parser to a user-defined one. # You have to restart the server for this to take effect. @@ -106,7 +107,12 @@ def get_and_merge_cmdsets(caller): # also in the caller's inventory and the location itself local_objlist = location.contents_get(exclude=caller.dbobj) + caller.contents + [location] local_objects_cmdsets = [obj.cmdset.current for obj in local_objlist - if obj.locks.check(caller, 'call', no_superuser_bypass=True)] + if (obj.cmdset.current and obj.locks.check(caller, 'call', no_superuser_bypass=True))] + for cset in local_objects_cmdsets: + #This is necessary for object sets, or we won't be able to separate + #the command sets from each other in a busy room. + cset.old_duplicates = cset.duplicates + cset.duplicates = True # Player object's commandsets try: @@ -128,127 +134,12 @@ def get_and_merge_cmdsets(caller): cmdset = merging_cmdset + cmdset else: cmdset = None + + for cset in (cset for cset in local_objects_cmdsets if cset): + cset.duplicates = cset.old_duplicates + return cmdset -def match_command(cmd_candidates, cmdset, logged_caller=None): - """ - Try to match the command against one of the - cmd_candidates. - - logged_caller - a logged-in object, if any. - - """ - - # Searching possible command matches in the given cmdset - matches = [] - prev_found_cmds = [] # to avoid aliases clashing with themselves - for cmd_candidate in cmd_candidates: - cmdmatches = list(set([cmd for cmd in cmdset - if cmd == cmd_candidate.cmdname and - cmd not in prev_found_cmds])) - matches.extend([(cmd_candidate, cmd) for cmd in cmdmatches]) - prev_found_cmds.extend(cmdmatches) - - if not matches or len(matches) == 1: - return matches - - # Do our damndest to resolve multiple matches ... - - # At this point we might still have several cmd candidates, - # each with a cmd match. We try to use candidate priority to - # separate them (for example this will give precedences to - # multi-word matches rather than one-word ones). - - top_ranked = [] - top_priority = None - for match in matches: - prio = match[0].priority - if top_priority == None or prio > top_priority: - top_ranked = [match] - top_priority = prio - elif top_priority == prio: - top_ranked.append(match) - - matches = top_ranked - if not matches or len(matches) == 1: - return matches - - # Still multiplies. At this point we should have sorted out - # all candidate multiples; the multiple comes from one candidate - # matching more than one command. - - # Check if player supplied - # an obj name on the command line (e.g. 'clock's open' would - # with the default parser tell us we want the open command - # associated with the clock and not, say, the open command on - # the door in the same location). It's up to the cmdparser to - # interpret and store this reference in candidate.obj_key if given. - - if logged_caller: - try: - local_objlist = logged_caller.location.contents - top_ranked = [] - candidate = matches[0][0] # all candidates should be the same - top_ranked.extend([(candidate, obj.cmdset.current.get(candidate.cmdname)) - for obj in local_objlist - if candidate.obj_key == obj.name - and any(cmd == candidate.cmdname - for cmd in obj.cmdset.current)]) - if top_ranked: - matches = top_ranked - except Exception: - logger.log_trace() - if not matches or len(matches) == 1: - return matches - - # We should still have only one candidate type, but matching - # several same-named commands. - - # Maybe the player tried to supply a separator in the form - # of a number (e.g. 1-door, 2-door for two different door exits)? If so, - # we pick the Nth-1 multiple as our result. It is up to the cmdparser - # to read and store this number in candidate.obj_key if given. - - candidate = matches[0][0] # all candidates should be the same - if candidate.obj_key and candidate.obj_key.isdigit(): - num = int(candidate.obj_key) - 1 - if 0 <= num < len(matches): - matches = [matches[num]] - - # regardless what we have at this point, we have to be content - return matches - -def format_multimatches(caller, matches): - """ - Format multiple command matches to a useful error. - """ - string = "There where multiple matches:" - for num, match in enumerate(matches): - # each match is a tuple (candidate, cmd) - candidate, cmd = match - - is_channel = hasattr(cmd, "is_channel") and cmd.is_channel - if is_channel: - is_channel = " (channel)" - else: - is_channel = "" - is_exit = hasattr(cmd, "is_exit") and cmd.is_exit - if is_exit and cmd.destination: - is_exit = " (exit to %s)" % cmd.destination - else: - is_exit = "" - - id1 = "" - id2 = "" - if not (is_channel or is_exit) and (hasattr(cmd, 'obj') and cmd.obj != caller): - # the command is defined on some other object - id1 = "%s-" % cmd.obj.name - id2 = " (%s-%s)" % (num + 1, candidate.cmdname) - else: - id1 = "%s-" % (num + 1) - id2 = "" - string += "\n %s%s%s%s%s" % (id1, candidate.cmdname, id2, is_channel, is_exit) - return string # Main command-handler function @@ -284,9 +175,9 @@ def cmdhandler(caller, raw_string, unloggedin=False, testing=False): sysarg = "" raise ExecSystemCommand(syscmd, sysarg) - # Parse the input string into command candidates - cmd_candidates = COMMAND_PARSER(raw_string) - + # Parse the input string and match to available cmdset. + matches = COMMAND_PARSER(raw_string, cmdset) + #string ="Command candidates" #for cand in cmd_candidates: # string += "\n %s || %s" % (cand.cmdname, cand.args) @@ -294,10 +185,10 @@ def cmdhandler(caller, raw_string, unloggedin=False, testing=False): # Try to produce a unique match between the merged # cmdset and the candidates. - if unloggedin: - matches = match_command(cmd_candidates, cmdset) - else: - matches = match_command(cmd_candidates, cmdset, caller) + # if unloggedin: + # matches = match_command(cmd_candidates, cmdset) + # else: + # matches = match_command(cmd_candidates, cmdset, caller) #print "matches: ", matches @@ -318,11 +209,12 @@ def cmdhandler(caller, raw_string, unloggedin=False, testing=False): if syscmd: syscmd.matches = matches else: - sysarg = format_multimatches(caller, matches) + sysarg = at_multimatch_cmd(caller, matches) raise ExecSystemCommand(syscmd, sysarg) # At this point, we have a unique command match. - cmd_candidate, cmd = matches[0] + match = matches[0] + cmdname, args, cmd = match[0], match[1], match[2] # Check so we have permission to use this command. if not cmd.access(caller): @@ -341,16 +233,15 @@ def cmdhandler(caller, raw_string, unloggedin=False, testing=False): if syscmd: # replace system command with custom version cmd = syscmd - sysarg = "%s:%s" % (cmd_candidate.cmdname, - cmd_candidate.args) + sysarg = "%s:%s" % (cmdname, args) raise ExecSystemCommand(cmd, sysarg) # A normal command. # Assign useful variables to the instance cmd.caller = caller - cmd.cmdstring = cmd_candidate.cmdname - cmd.args = cmd_candidate.args + cmd.cmdstring = cmdname + cmd.args = args cmd.cmdset = cmdset if hasattr(cmd, 'obj') and hasattr(cmd.obj, 'scripts'): @@ -384,10 +275,10 @@ def cmdhandler(caller, raw_string, unloggedin=False, testing=False): syscmd.args = sysarg syscmd.cmdset = cmdset - if hasattr(cmd, 'obj') and hasattr(cmd.obj, 'scripts'): + if hasattr(syscmd, 'obj') and hasattr(syscmd.obj, 'scripts'): # cmd.obj is automatically made available. # we make sure to validate its scripts. - cmd.obj.scripts.validate() + syscmd.obj.scripts.validate() if testing: # only return the command instance diff --git a/src/commands/cmdparser.py b/src/commands/cmdparser.py index ba30c8e11d..9563774c9e 100644 --- a/src/commands/cmdparser.py +++ b/src/commands/cmdparser.py @@ -4,177 +4,90 @@ settings.ALTERNATE_PARSER to a Python path to a module containing the replacing cmdparser function. The replacement parser must return a CommandCandidates object. """ -import re -from django.conf import settings -# This defines how many space-separated words may at most be in a command. -COMMAND_MAXLEN = settings.COMMAND_MAXLEN - -# These chars (and space) end a command name and may -# thus never be part of a command name. Exception is -# if the char is the very first character - the char -# is then treated as the name of the command. -SPECIAL_CHARS = ["/", "\\", "'", '"', ":", ";", "\-", '#', '=', '!'] - -# Pre-compiling the regular expression is more effective -REGEX = re.compile(r"""["%s"]""" % ("".join(SPECIAL_CHARS))) - - -class CommandCandidate(object): +def cmdparser(raw_string, cmdset, match_index=None): """ - This is a convenient container for one possible - combination of command names that may appear if we allow - many-word commands. - """ - def __init__(self, cmdname, args=0, priority=0, obj_key=None): - "initiate" - self.cmdname = cmdname - self.args = args - self.priority = priority - self.obj_key = obj_key - def __str__(self): - string = "cmdcandidate is one of the SPECIAL_CHARs - defined globally.): + [] marks optional parts. - []cmdname[ cmdname2 cmdname3 ...][] [the rest] + [cmdname[ cmdname2 cmdname3 ...] [the rest] - A command may contain spaces, but never any of of the s. A - command can maximum have CMD_MAXLEN words, or the number of words - up to the first , whichever is smallest. An exception is if - is the very first character in the string - the is - then assumed to be the actual command name (a common use for this - is for e.g ':' to be a shortcut for 'emote'). - All words not part of the command name is considered a part of the - command's argument. Note that s ending a command are never - removed but are included as the first character in the - argument. This makes it easier for individual commands to identify - things like switches. Example: '@create/drop ball' finds the - command name to trivially be '@create' since '/' ends it. As the - command's arguments are sent '/drop ball'. In this MUX-inspired - example, '/' denotes a keyword (or switch) and it is now easy for - the receiving command to parse /drop as a keyword just by looking - at the first character. + A command may consist of any number of space-separated words of any + length, and contain any character. + + The parser makes use of the cmdset to find command candidates. The + parser return a list of matches. Each match is a tuple with its + first three elements being the parsed cmdname (lower case), + the remaining arguments, and the matched cmdobject from the cmdset. + """ - Allowing multiple command names means we have to take care of all - possible meanings and the result will be a CommandCandidates - object with up to COMMAND_MAXLEN names stored in it. So if - COMMAND_MAXLEN was, say, 4, we would have to search all commands - matching one of 'hit', 'hit orc', 'hit orc with' and 'hit orc with - sword' - each which are potentially valid commands. Assuming a - longer written name means being more specific, a longer command - name takes precedence over a short one. + def create_match(cmdname, string, cmdobj): + """ + Evaluates the quality of a match by counting how many chars of cmdname + matches string (counting from beginning of string). We also calculate + a ratio from 0-1 describing how much cmdname matches string. + We return a tuple (cmdname, count, ratio, args, cmdobj). - There are two optional forms: - -[]cmdname[ cmdname2 cmdname3 ...][] [the rest] - -[]cmdname[ cmdname2 cmdname3 ...][] [the rest] + """ + cmdlen, strlen = len(cmdname), len(string) + mratio = 1 - (strlen - cmdlen) / (1.0 * strlen) + args = string[cmdlen:] + return (cmdname, args, cmdobj, cmdlen, mratio) - This allows for the user to manually choose between unresolvable - command matches. The main use for this is probably for Exit-commands. - The - identifier is used to differentiate between same-named - commands on different objects. E.g. if a 'watch' and a 'door' both - have a command 'open' defined on them, the user could differentiate - between them with - > watch-open - Alternatively, if they know (and the Multiple-match error reports - it correctly), the number among the multiples may be picked with - the - identifier: - > 2-open + if not raw_string: + return None - """ + matches = [] - def produce_candidates(nr_candidates, wordlist): - "Helper function" - candidates = [] - cmdwords_list = [] - for n_words in range(nr_candidates): - cmdwords_list.append(wordlist.pop(0)) - cmdwords = " ".join([word.strip().lower() - for word in cmdwords_list]) - args = "" - for word in wordlist: - if not args or (word and (REGEX.search(word[0]))): - #print "nospace: %s '%s'" % (args, word) - args += word - else: - #print "space: %s '%s'" % (args, word) - args += " %s" % word - #print "'%s' | '%s'" % (cmdwords, args) - candidates.append(CommandCandidate(cmdwords, args, priority=n_words)) - return candidates + # match everything that begins with a matching cmdname. + l_raw_string = raw_string.lower() + for cmd in cmdset: + matches.extend([create_match(cmdname, raw_string, cmd) + for cmdname in [cmd.key] + cmd.aliases + if cmdname and l_raw_string.startswith(cmdname.lower())]) + + if not matches: + # no matches found. + if '-' in raw_string: + # This could be due to the user trying to identify the + # command with a #num- style syntax. + mindex, new_raw_string = raw_string.split("-", 1) + if mindex.isdigit(): + mindex = int(mindex) - 1 + # feed result back to parser iteratively + return cmdparser(new_raw_string, cmdset, match_index=mindex) - raw_string = raw_string.strip() - candidates = [] - - regex_result = REGEX.search(raw_string) + if len(matches) > 1: + # see if it helps to analyze the match with preserved case. + matches = [match for match in matches if raw_string.startswith(match[0])] - if not regex_result == None: - # there are characters from SPECIAL_CHARS in the string. - # since they cannot be part of a longer command, these - # will cut short the command, no matter how long we - # allow commands to be. + if len(matches) > 1: + # we still have multiple matches. Sort them by count quality. + matches = sorted(matches, key=lambda m: m[3]) + # only pick the matches with highest count quality + quality = [mat[3] for mat in matches] + matches = matches[-quality.count(quality[-1]):] - end_index = regex_result.start() - end_char = raw_string[end_index] + if len(matches) > 1: + # still multiple matches. Fall back to ratio-based quality. + matches = sorted(matches, key=lambda m: m[4]) + # only pick the highest rated ratio match + quality = [mat[4] for mat in matches] + matches = matches[-quality.count(quality[-1]):] - if end_index == 0: - # There is one exception: if the input *begins* with - # a special char, we let that be the command name. - cmdwords = end_char - if len(raw_string) > 1: - args = raw_string[1:] - else: - args = "" - candidates.append(CommandCandidate(cmdwords, args)) - return candidates - else: - # the special char occurred somewhere inside the string - if end_char == "-" and len(raw_string) > end_index+1: - # the command is on the forms "-command" - # or "-command" - obj_key = raw_string[:end_index] - alt_string = raw_string[end_index+1:] - for candidate in cmdparser(alt_string): - candidate.obj_key = obj_key - candidate.priority =- 1 - candidates.append(candidate) - - # We have dealt with the special possibilities. We now continue - # in case they where just accidental. - # We only run the command finder up until the end char - nr_candidates = len(raw_string[:end_index].split(None)) - if nr_candidates <= COMMAND_MAXLEN: - wordlist = raw_string[:end_index].split(" ") - wordlist.extend(raw_string[end_index:].split(" ")) - #print "%i, wordlist: %s" % (nr_candidates, wordlist) - candidates.extend(produce_candidates(nr_candidates, wordlist)) - return candidates + if len(matches) > 1 and match_index != None and 0 <= match_index < len(matches): + # We couldn't separate match by quality, but we have an index argument to + # tell us which match to use. + matches = [matches[match_index]] - # if there were no special characters, or that character - # was not found within the allowed number of words, we run normally - nr_candidates = min(COMMAND_MAXLEN, - len(raw_string.split(None))) - wordlist = raw_string.split(" ") - candidates.extend(produce_candidates(nr_candidates, wordlist)) - return candidates + # no matter what we have at this point, we have to return it. + return matches + #------------------------------------------------------------ # Search parsers and support methods @@ -299,3 +212,36 @@ def at_multimatch_input(ostring): return (None, ostring) except IndexError: return (None, ostring) + + +def at_multimatch_cmd(caller, matches): + """ + Format multiple command matches to a useful error. + """ + string = "There where multiple matches:" + for num, match in enumerate(matches): + # each match is a tuple (candidate, cmd) + cmdname, arg, cmd, dum, dum = match + + is_channel = hasattr(cmd, "is_channel") and cmd.is_channel + if is_channel: + is_channel = " (channel)" + else: + is_channel = "" + is_exit = hasattr(cmd, "is_exit") and cmd.is_exit + if is_exit and cmd.destination: + is_exit = " (exit to %s)" % cmd.destination + else: + is_exit = "" + + id1 = "" + id2 = "" + if not (is_channel or is_exit) and (hasattr(cmd, 'obj') and cmd.obj != caller): + # the command is defined on some other object + id1 = "%s-" % cmd.obj.key + id2 = " (%s-%s)" % (num + 1, cmdname) + else: + id1 = "%s-" % (num + 1) + id2 = "" + string += "\n %s%s%s%s%s" % (id1, cmdname, id2, is_channel, is_exit) + return string diff --git a/src/commands/cmdset.py b/src/commands/cmdset.py index dd8dff5648..2a79d58d91 100644 --- a/src/commands/cmdset.py +++ b/src/commands/cmdset.py @@ -30,7 +30,8 @@ class CmdSetMeta(type): """ # by default we key the cmdset the same as the # name of its class. - mcs.key = mcs.__name__ + if not hasattr(mcs, 'key') or not mcs.key: + mcs.key = mcs.__name__ mcs.path = "%s.%s" % (mcs.__module__, mcs.__name__) if not type(mcs.key_mergetypes) == dict: @@ -178,7 +179,7 @@ class CmdSet(object): key_mergetypes = {} no_exits = False no_objs = False - no_channels = False + no_channels = False def __init__(self, cmdsetobj=None, key=None): """ diff --git a/src/commands/cmdsethandler.py b/src/commands/cmdsethandler.py index 4917ee365b..2c9ed038e3 100644 --- a/src/commands/cmdsethandler.py +++ b/src/commands/cmdsethandler.py @@ -92,7 +92,7 @@ def import_cmdset(python_path, cmdsetobj, emit_to_obj=None, no_logging=False): cmdsetclass = CACHED_CMDSETS.get(wanted_cache_key, None) errstring = "" if not cmdsetclass: - #print "cmdset %s not in cache. Reloading." % wanted_cache_key + #print "cmdset '%s' not in cache. Reloading %s on %s." % (wanted_cache_key, python_path, cmdsetobj) # Not in cache. Reload from disk. modulepath, classname = python_path.rsplit('.', 1) module = __import__(modulepath, fromlist=[True]) @@ -120,8 +120,8 @@ def import_cmdset(python_path, cmdsetobj, emit_to_obj=None, no_logging=False): print errstring logger.log_trace() if emit_to_obj and not ServerConfig.objects.conf("server_starting_mode"): - object.__getattribute__(emit_to_obj, "msg")(errstring) - raise # have to raise, or we will not see any errors in some situations! + object.__getattribute__(emit_to_obj, "msg")(errstring) + #raise # have to raise, or we will not see any errors in some situations! # classes @@ -201,17 +201,19 @@ class CmdSetHandler(object): """ if init_mode: # reimport all permanent cmdsets - self.permanent_paths = self.obj.cmdset_storage - if self.permanent_paths: + storage = self.obj.cmdset_storage + #print "cmdset_storage:", self.obj.cmdset_storage + if storage: self.cmdset_stack = [] - for pos, path in enumerate(self.permanent_paths): + for pos, path in enumerate(storage): if pos == 0 and not path: self.cmdset_stack = [CmdSet(cmdsetobj=self.obj, key="Empty")] - else: - cmdset = self.import_cmdset(path) + elif path: + cmdset = self.import_cmdset(path) if cmdset: + cmdset.permanent = True self.cmdset_stack.append(cmdset) - + # merge the stack into a new merged cmdset new_current = None self.mergetype_stack = [] @@ -226,6 +228,7 @@ class CmdSetHandler(object): def import_cmdset(self, cmdset_path, emit_to_obj=None): """ + Method wrapper for import_cmdset. load a cmdset from a module. cmdset_path - the python path to an cmdset object. emit_to_obj - object to send error messages to @@ -243,8 +246,7 @@ class CmdSetHandler(object): cmdset - can be a cmdset object or the python path to such an object. emit_to_obj - an object to receive error messages. - permanent - create a script to automatically add the cmdset - every time the server starts/the object logins. + permanent - this cmdset will remain across a server reboot Note: An interesting feature of this method is if you were to send it an *already instantiated cmdset* (i.e. not a class), @@ -260,16 +262,17 @@ class CmdSetHandler(object): cmdset = cmdset(self.obj) elif isinstance(cmdset, basestring): # this is (maybe) a python path. Try to import from cache. - cmdset = self.import_cmdset(cmdset)#, emit_to_obj) + cmdset = self.import_cmdset(cmdset) if cmdset: - self.cmdset_stack.append(cmdset) if permanent: # store the path permanently - self.permanent_paths.append(cmdset.path) - self.obj.cmdset_storage = self.permanent_paths + cmdset.permanent = True + storage = self.obj.cmdset_storage + storage.append(cmdset.path) + self.obj.cmdset_storage = storage else: - # store an empty entry and don't save (this makes it easy to delete). - self.permanent_paths.append("") + cmdset.permanent = False + self.cmdset_stack.append(cmdset) self.update() def add_default(self, cmdset, emit_to_obj=None, permanent=True): @@ -298,16 +301,15 @@ class CmdSetHandler(object): self.mergetype_stack = [cmdset.mergetype] if permanent: - if self.permanent_paths: - self.permanent_paths[0] = cmdset.path + cmdset.permanent = True + storage = self.obj.cmdset_storage + if storage: + storage[0] = cmdset.path else: - self.permanent_paths = [cmdset.path] - self.obj.cmdset_storage = self.permanent_paths + storage = [cmdset.path] + self.obj.cmdset_storage = storage else: - if self.permanent_paths: - self.permanent_paths[0] = "" - else: - self.permanent_paths = [""] + cmdset.permanent = False self.update() def delete(self, cmdset=None): @@ -328,34 +330,53 @@ class CmdSetHandler(object): return if not cmdset: - # remove the last one in the stack (except the default position) - self.cmdset_stack.pop() - self.permanent_paths.pop() + # remove the last one in the stack + cmdset = self.cmdset_stack.pop() + if cmdset.permanent: + storage = self.obj.cmdset_storage + storage.pop() + self.obj.cmdset_storage = storage else: # try it as a callable if callable(cmdset) and hasattr(cmdset, 'path'): - indices = [i+1 for i, cset in enumerate(self.cmdset_stack[1:]) if cset.path == cmdset.path] + delcmdsets = [cset for cset in self.cmdset_stack[1:] if cset.path == cmdset.path] else: # try it as a path or key - indices = [i+1 for i, cset in enumerate(self.cmdset_stack[1:]) if cset.path == cmdset or cset.key == cmdset] - - for i in indices: - del self.cmdset_stack[i] - del self.permanent_paths[i] - self.obj.cmdset_storage = self.permanent_paths - + delcmdsets = [cset for cset in self.cmdset_stack[1:] if cset.path == cmdset or cset.key == cmdset] + storage = [] + + if any(cset.permanent for cset in delcmdsets): + # only hit database if there's need to + storage = self.obj.cmdset_storage + for cset in delcmdsets: + if cset.permanent: + try: + storage.remove(cset.path) + except ValueError: + pass + for cset in delcmdsets: + # clean the in-memory stack + try: + self.cmdset_stack.remove(cset) + except ValueError: + pass # re-sync the cmdsethandler. self.update() def delete_default(self): "This explicitly deletes the default cmdset. It's the only command that can." if self.cmdset_stack: + cmdset = self.cmdet_stack[0] + if cmdset.permanent: + storage = self.obj.cmdset_storage + if storage: + storage[0] = "" + else: + storage = [""] + self.cmdset_storage = storage self.cmdset_stack[0] = CmdSet(cmdsetobj=self.obj, key="Empty") - self.permanent_paths[0] = "" - else: + else: self.cmdset_stack = [CmdSet(cmdsetobj=self.obj, key="Empty")] - self.permanent_paths = [""] - self.obj.cmdset_storage = self.permanent_paths self.update() def all(self): @@ -371,8 +392,10 @@ class CmdSetHandler(object): """ self.cmdset_stack = [self.cmdset_stack[0]] self.mergetype_stack = [self.cmdset_stack[0].mergetype] - self.permanent_paths = [self.permanent_paths[0]] - self.obj.cmdset_storage = self.permanent_paths + storage = self.obj.cmdset_storage + if storage: + storage = storage[0] + self.obj.cmdset_storage = storage self.update() def all(self): diff --git a/src/commands/command.py b/src/commands/command.py index a8dc21a94c..1c8e8af9eb 100644 --- a/src/commands/command.py +++ b/src/commands/command.py @@ -21,8 +21,11 @@ class CommandMeta(type): """ mcs.key = mcs.key.lower() if mcs.aliases and not is_iter(mcs.aliases): - mcs.aliases = mcs.aliases.split(',') - mcs.aliases = [str(alias).strip().lower() for alias in mcs.aliases] + try: + mcs.aliases = mcs.aliases.split(',') + except Exception: + mcs.aliases = [] + mcs.aliases = [str(alias).strip() for alias in mcs.aliases] # pre-process locks as defined in class definition temp = [] diff --git a/src/commands/default/building.py b/src/commands/default/building.py index c97ae00d98..f461016a7d 100644 --- a/src/commands/default/building.py +++ b/src/commands/default/building.py @@ -390,7 +390,9 @@ class CmdCreate(ObjManipCommand): # (i.e. starts with game or src) we let it be, otherwise we # add a base path as defined in settings if typeclass and not (typeclass.startswith('src.') or - typeclass.startswith('game.')): + typeclass.startswith('game.') or + typeclass.startswith('contrib')): + typeclass = "%s.%s" % (settings.BASE_TYPECLASS_PATH, typeclass) @@ -1477,13 +1479,13 @@ class CmdExamine(ObjManipCommand): #self.caller.msg(db_attr) string += headers["persistent"] for attr, value in db_attr: - if crop: + if crop and isinstance(value, basestring): value = utils.crop(value) string += "\n %s = %s" % (attr, value) if ndb_attr and ndb_attr[0]: string += headers["nonpersistent"] for attr, value in ndb_attr: - if crop: + if crop and isinstance(value, basestring): value = utils.crop(value) string += "\n %s = %s" % (attr, value) return string diff --git a/src/commands/default/general.py b/src/commands/default/general.py index 85d7996ebe..1d3b58f762 100644 --- a/src/commands/default/general.py +++ b/src/commands/default/general.py @@ -485,7 +485,7 @@ class CmdPose(MuxCommand): """ args = self.args if args and not args[0] in ["'", ",", ":"]: - args = " %s" % args + args = " %s" % args.strip() self.args = args def func(self): diff --git a/src/commands/default/tests.py b/src/commands/default/tests.py index 2ecf45cd8b..b9dc34aee8 100644 --- a/src/commands/default/tests.py +++ b/src/commands/default/tests.py @@ -23,7 +23,10 @@ from src.utils import create, ansi from src.server import session, sessionhandler from src.locks.lockhandler import LockHandler from src.server.models import ServerConfig -from src.comms.models import Channel, Msg, PlayerChannelConnection +from src.comms.models import Channel, Msg, PlayerChannelConnection, ExternalChannelConnection +from django.contrib.auth.models import User +from src.players.models import PlayerDB +from src.objects.models import ObjectDB #------------------------------------------------------------ # Command testing @@ -32,6 +35,17 @@ from src.comms.models import Channel, Msg, PlayerChannelConnection # print all feedback from test commands (can become very verbose!) VERBOSE = False + +def cleanup(): + User.objects.all().delete() + PlayerDB.objects.all().delete() + ObjectDB.objects.all().delete() + Channel.objects.all().delete() + Msg.objects.all().delete() + PlayerChannelConnection.objects.all().delete() + ExternalChannelConnection.objects.all().delete() + ServerConfig.objects.all().delete() + class FakeSession(session.Session): """ A fake session that @@ -76,15 +90,16 @@ class CommandTest(TestCase): Inherit new tests from this. """ - NOMANGLE = False # mangle command input for extra testing + NOMANGLE = True # mangle command input for extra testing def setUp(self): "sets up the testing environment" ServerConfig.objects.conf("default_home", 2) + + self.addCleanup(cleanup) self.room1 = create.create_object(settings.BASE_ROOM_TYPECLASS, key="room1") - self.room2 = create.create_object(settings.BASE_ROOM_TYPECLASS, key="room2") - + self.room2 = create.create_object(settings.BASE_ROOM_TYPECLASS, key="room2") # create a faux player/character for testing. self.char1 = create.create_player("TestChar", "testplayer@test.com", "testpassword", location=self.room1) self.char1.player.user.is_superuser = True @@ -111,6 +126,17 @@ class CommandTest(TestCase): self.exit1 = create.create_object(settings.BASE_EXIT_TYPECLASS, key="exit1", location=self.room1) self.exit2 = create.create_object(settings.BASE_EXIT_TYPECLASS, key="exit2", location=self.room2) + def tearDown(self): + "Cleans up testing environment after test has run." + User.objects.all().delete() + PlayerDB.objects.all().delete() + ObjectDB.objects.all().delete() + Channel.objects.all().delete() + Msg.objects.all().delete() + PlayerChannelConnection.objects.all().delete() + ExternalChannelConnection.objects.all().delete() + ServerConfig.objects.all().delete() + def get_cmd(self, cmd_class, argument_string=""): """ Obtain a cmd instance from a class and an input string @@ -401,15 +427,16 @@ class TestChannelCreate(CommandTest): self.execute_cmd("@ccreate testchannel1;testchan1;testchan1b = This is a test channel") self.execute_cmd("testchan1 Hello", "[testchannel1] TestChar: Hello") class TestAddCom(CommandTest): - def test_call(self): + def test_call(self): + self.execute_cmd("@cdestroy testchannel1", "Channel 'testchannel1'") self.execute_cmd("@ccreate testchannel1;testchan1;testchan1b = This is a test channel") self.execute_cmd("addcom chan1 = testchannel1") self.execute_cmd("addcom chan2 = testchan1") self.execute_cmd("delcom testchannel1") self.execute_cmd("addcom testchannel1" "You now listen to the channel channel.") - class TestDelCom(CommandTest): def test_call(self): + self.execute_cmd("@cdestroy testchannel1", "Channel 'testchannel1'") self.execute_cmd("@ccreate testchannel1;testchan1;testchan1b = This is a test channel") self.execute_cmd("addcom chan1 = testchan1") self.execute_cmd("addcom chan2 = testchan1b") @@ -430,7 +457,9 @@ class TestChannels(CommandTest): self.execute_cmd("@cdestroy testchannel1", "Channel 'testchannel1'") class TestCBoot(CommandTest): def test_call(self): + self.execute_cmd("@cdestroy testchannel1", "Channel 'testchannel1'") self.execute_cmd("@ccreate testchannel1;testchan1;testchan1b = This is a test channel") + self.execute_cmd("addcom testchan = testchannel1") self.execute_cmd("@cboot testchannel1 = TestChar", "TestChar boots TestChar from channel.") class TestCemit(CommandTest): def test_call(self): diff --git a/src/settings_default.py b/src/settings_default.py index 2a2e2eca34..f7d5bd38a4 100644 --- a/src/settings_default.py +++ b/src/settings_default.py @@ -137,12 +137,8 @@ DATABASE_PORT = '' # An alternate command parser module to use COMMAND_PARSER = "src.commands.cmdparser.cmdparser" -# How many space-separated words a command name may have -# and still be identified as one single command -# (e.g. 'push button' instead of 'pushbutton') -COMMAND_MAXLEN = 3 # The handler that outputs errors when searching -# objects using object.search(). +# objects using object.search(). SEARCH_AT_RESULT = "src.commands.cmdparser.at_search_result" # The parser used in order to separate multiple # object matches (so you can separate between same-named diff --git a/src/utils/utils.py b/src/utils/utils.py index 60669fa4d8..cdd757ae65 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -52,12 +52,12 @@ def crop(text, width=78, suffix="[...]"): continues. Cropping will be done so that the suffix will also fit within the given width. """ - ltext = len(str(text)) + ltext = len(to_str(text)) if ltext <= width: return text else: lsuffix = len(suffix) - return text[:width-lsuffix] + suffix + return "%s%s" % (text[:width-lsuffix], suffix) def dedent(text): """