Source code for evennia.commands.default.help

"""
The help command. The basic idea is that help texts for commands are best
written by those that write the commands - the developers. So command-help is
all auto-loaded and searched from the current command set. The normal,
database-tied help system is used for collaborative creation of other help
topics such as RP help or game-world aides. Help entries can also be created
outside the game in modules given by ``settings.FILE_HELP_ENTRY_MODULES``.

"""

import re
from dataclasses import dataclass
from django.conf import settings
from collections import defaultdict
from evennia.utils.utils import fill, dedent
from evennia.help.models import HelpEntry
from evennia.utils import create, evmore
from evennia.utils.ansi import ANSIString
from evennia.help.filehelp import FILE_HELP_ENTRIES
from evennia.utils.eveditor import EvEditor
from evennia.utils.utils import (
    class_from_module,
    inherits_from,
    format_grid, pad
)
from evennia.help.utils import help_search_with_index, parse_entry_for_subcategories

COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
HELP_MORE_ENABLED = settings.HELP_MORE_ENABLED
DEFAULT_HELP_CATEGORY = settings.DEFAULT_HELP_CATEGORY

# limit symbol import for API
__all__ = ("CmdHelp", "CmdSetHelp")

@dataclass
class HelpCategory:
    """
    Mock 'help entry' to search categories with the same code.

    """
    key: str

    @property
    def search_index_entry(self):
        return {
            "key": self.key,
            "aliases": "",
            "category": self.key,
            "tags": "",
            "text": "",
        }

    def __hash__(self):
        return hash(id(self))


[docs]class CmdHelp(COMMAND_DEFAULT_CLASS): """ Get help. Usage: help help <topic, command or category> help <topic>/<subtopic> help <topic>/<subtopic>/<subsubtopic> ... Use the 'help' command alone to see an index of all help topics, organized by category.eSome big topics may offer additional sub-topics. """ key = "help" aliases = ["?"] 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(). return_cmdset = True # Help messages are wrapped in an EvMore call (unless using the webclient # with separate help popups) If you want to avoid this, simply add # 'HELP_MORE_ENABLED = False' in your settings/conf/settings.py help_more = HELP_MORE_ENABLED # colors for the help index index_type_separator_clr = "|w" index_category_clr = "|W" index_topic_clr = "|G" # suggestion cutoff, between 0 and 1 (1 => perfect match) suggestion_cutoff = 0.6 # number of suggestions (set to 0 to remove suggestions from help) suggestion_maxnum = 5 # separator between subtopics: subtopic_separator_char = r"/"
[docs] def msg_help(self, text): """ messages text to the caller, adding an extra oob argument to indicate that this is a help command result and could be rendered in a separate help window """ if type(self).help_more: usemore = True if self.session and self.session.protocol_key in ("websocket", "ajax/comet",): try: options = self.account.db._saved_webclient_options if options and options["helppopup"]: usemore = False except KeyError: pass if usemore: evmore.msg(self.caller, text, session=self.session) return self.msg(text=(text, {"type": "help"}))
[docs] def format_help_entry(self, topic="", help_text="", aliases=None, suggested=None, subtopics=None): """ This visually formats the help entry. This method can be overriden to customize the way a help entry is displayed. Args: title (str): The title of the help entry. help_text (str): Text of the help entry. aliases (list): List of help-aliases (displayed in header). suggested (list): Strings suggested reading (based on title). subtopics (list): A list of strings - the subcategories available for this entry. Returns the formatted string, ready to be sent. """ separator = "|C" + "-" * self.client_width() + "|n" start = f"{separator}\n" title = f"|CHelp for |w{topic}|n" if topic else "|rNo help found|n" if aliases: aliases = ( " |C(aliases: {}|C)|n".format("|C,|n ".join(f"|w{ali}|n" for ali in aliases)) ) else: aliases = '' help_text = "\n" + dedent(help_text.strip('\n')) if help_text else "" if subtopics: subtopics = [f"|w{topic}/{subtop}|n" for subtop in subtopics] subtopics = ( "\n|CSubtopics:|n\n {}".format( "\n ".join(format_grid(subtopics, width=self.client_width()))) ) else: subtopics = '' if suggested: suggested = [f"|w{sug}|n" for sug in suggested] suggested = ( "\n|COther topic suggestions:|n\n{}".format( "\n ".join(format_grid(suggested, width=self.client_width()))) ) else: suggested = '' end = start partorder = (start, title + aliases, help_text, subtopics, suggested, end) return "\n".join(part.rstrip() for part in partorder if part)
[docs] def format_help_index(self, cmd_help_dict=None, db_help_dict=None, title_lone_category=False): """ Output a category-ordered g for displaying the main help, grouped by category. Args: cmd_help_dict (dict): A dict `{"category": [topic, topic, ...]}` for command-based help. db_help_dict (dict): A dict `{"category": [topic, topic], ...]}` for database-based help. title_lone_category (bool, optional): If a lone category should be titled with the category name or not. While pointless in a general index, the title should probably show when explicitly listing the category itself. Returns: str: The help index organized into a grid. The input are the pre-loaded help files for commands and database-helpfiles respectively. You can override this method to return a custom display of the list of commands and topics. """ def _group_by_category(help_dict): grid = [] verbatim_elements = [] if len(help_dict) == 1 and not title_lone_category: # don't list categories if there is only one for category in help_dict: entries = sorted(set(help_dict.get(category, []))) grid.extend(entries) else: # list the categories for category in sorted(set(list(help_dict.keys()))): category_str = f"-- {category.title()} " grid.append( ANSIString( self.index_category_clr + category_str + "-" * (width - len(category_str)) + self.index_topic_clr ) ) verbatim_elements.append(len(grid) - 1) entries = sorted(set(help_dict.get(category, []))) grid.extend(entries) return grid, verbatim_elements help_index = "" width = self.client_width() grid = [] verbatim_elements = [] cmd_grid, db_grid = "", "" if any(cmd_help_dict.values()): # get the command-help entries by-category sep1 = (self.index_type_separator_clr + pad("Commands", width=width, fillchar='-') + self.index_topic_clr) grid, verbatim_elements = _group_by_category(cmd_help_dict) gridrows = format_grid(grid, width, sep=" ", verbatim_elements=verbatim_elements) cmd_grid = ANSIString("\n").join(gridrows) if gridrows else "" if any(db_help_dict.values()): # get db-based help entries by-category sep2 = (self.index_type_separator_clr + pad("Game & World", width=width, fillchar='-') + self.index_topic_clr) grid, verbatim_elements = _group_by_category(db_help_dict) gridrows = format_grid(grid, width, sep=" ", verbatim_elements=verbatim_elements) db_grid = ANSIString("\n").join(gridrows) if gridrows else "" # only show the main separators if there are actually both cmd and db-based help if cmd_grid and db_grid: help_index = f"{sep1}\n{cmd_grid}\n{sep2}\n{db_grid}" else: help_index = f"{cmd_grid}{db_grid}" return help_index
[docs] def check_show_help(self, cmd, caller): """ Helper method. If this return True, the given cmd auto-help will be viewable in the help listing. Override this to easily select what is shown to the account. Note that only commands available in the caller's merged cmdset are available. Args: cmd (Command): Command class from the merged cmdset caller (Character, Account or Session): The current caller executing the help command. """ # return only those with auto_help set and passing the cmd: lock return cmd.auto_help and cmd.access(caller)
[docs] def should_list_cmd(self, cmd, caller): """ Should the specified command appear in the help table? This method only checks whether a specified command should appear in the table of topics/commands. The command can be used by the caller (see the 'check_show_help' method) and the command will still be available, for instance, if a character type 'help name of the command'. However, if you return False, the specified command will not appear in the table. This is sometimes useful to "hide" commands in the table, but still access them through the help system. Args: cmd: the command to be tested. caller: the caller of the help system. Return: True: the command should appear in the table. False: the command shouldn't appear in the table. """ return True
[docs] def parse(self): """ input is a string containing the command or topic to match. The allowed syntax is :: help <topic>[/<subtopic>[/<subtopic>[/...]]] The database/command query is always for `<topic>`, and any subtopics is then parsed from there. If a `<topic>` has spaces in it, it is always matched before assuming the space begins a subtopic. """ # parse the query if self.args: self.subtopics = [part.strip().lower() for part in self.args.split(self.subtopic_separator_char)] self.topic = self.subtopics.pop(0) else: self.topic = "" self.subtopics = []
[docs] def func(self): """ Run the dynamic help entry creator. """ caller = self.caller query, subtopics, cmdset = self.topic, self.subtopics, self.cmdset # removing doublets in cmdset, caused by cmdhandler # having to allow doublet commands to manage exits etc. cmdset.make_unique(caller) # retrieve all available commands and database / file-help topics all_cmds = [cmd for cmd in cmdset if self.check_show_help(cmd, caller)] # we group the file-help topics with the db ones, giving the db ones priority file_help_topics = FILE_HELP_ENTRIES.all(return_dict=True) db_topics = { topic.key.lower().strip(): topic for topic in HelpEntry.objects.all() if topic.access(caller, "view", default=True) } all_db_topics = list({**file_help_topics, **db_topics}.values()) all_categories = list(set( [HelpCategory(cmd.help_category) for cmd in all_cmds] + [HelpCategory(topic.help_category) for topic in all_db_topics] )) if not query: # list all available help entries, grouped by category. We want to # build dictionaries {category: [topic, topic, ...], ...} cmd_help_dict = defaultdict(list) db_help_dict = defaultdict(list) # Filter commands that should be reached by the help # system, but not be displayed in the table, or be displayed differently. for cmd in all_cmds: if self.should_list_cmd(cmd, caller): key = (cmd.auto_help_display_key if hasattr(cmd, "auto_help_display_key") else cmd.key) cmd_help_dict[cmd.help_category].append(key) for db_topic in all_db_topics: db_help_dict[db_topic.help_category].append(db_topic.key) output = self.format_help_index(cmd_help_dict, db_help_dict) self.msg_help(output) return # We have a query - try to find a specific topic/category using the # Lunr search engine # all available options entries = [cmd for cmd in all_cmds if cmd] + all_db_topics + all_categories # lunr search fields/boosts search_fields = [ {"field_name": "key", "boost": 10}, {"field_name": "aliases", "boost": 9}, {"field_name": "category", "boost": 8}, {"field_name": "tags", "boost": 1}, # tags are not used by default ] match, suggestions = None, None for match_query in (query, f"{query}*"): # We first do an exact word-match followed by a start-by query. The # return of this will either be a HelpCategory, a Command or a # HelpEntry/FileHelpEntry. matches, suggestions = help_search_with_index( match_query, entries, suggestion_maxnum=self.suggestion_maxnum, fields=search_fields ) if matches: match = matches[0] break if not match: # no topic matches found. Only give suggestions. help_text = f"There is no help topic matching '{query}'." if not suggestions: # we don't even have a good suggestion. Run a second search, # doing a full-text search in the actual texts of the help # entries search_fields = [ {"field_name": "text", "boost": 1}, ] for match_query in [query, f"{query}*"]: _, suggestions = help_search_with_index( match_query, entries, suggestion_maxnum=self.suggestion_maxnum, fields=search_fields ) if suggestions: help_text += ( "\n... But matches where found within the help " "texts of the suggestions below.") break output = self.format_help_entry( topic=None, # this will give a no-match style title help_text=help_text, suggested=suggestions ) self.msg_help(output) return if isinstance(match, HelpCategory): # no subtopics for categories - these are just lists of topics output = self.format_help_index( { match.key: [ cmd.key for cmd in all_cmds if match.key.lower() == cmd.help_category ] }, { match.key: [ topic.key for topic in all_db_topics if match.key.lower() == topic.help_category ] }, title_lone_category=True ) self.msg_help(output) return if inherits_from(match, "evennia.commands.command.Command"): # a command match topic = match.key help_text = match.get_help(caller, cmdset) aliases = match.aliases suggested = suggestions[1:] else: # a database (or file-help) match topic = match.key help_text = match.entrytext aliases = match.aliases if isinstance(match.aliases, list) else match.aliases.all() suggested = suggestions[1:] # parse for subtopics. The subtopic_map is a dict with the current topic/subtopic # text is stored under a `None` key and all other keys are subtopic titles pointing # to nested dicts. subtopic_map = parse_entry_for_subcategories(help_text) help_text = subtopic_map[None] subtopic_index = [subtopic for subtopic in subtopic_map if subtopic is not None] if subtopics: # if we asked for subtopics, parse the found topic_text to see if any match. # the subtopics is a list describing the path through the subtopic_map. for subtopic_query in subtopics: if subtopic_query not in subtopic_map: # exact match failed. Try startswith-match fuzzy_match = False for key in subtopic_map: if key and key.startswith(subtopic_query): subtopic_query = key fuzzy_match = True break if not fuzzy_match: # startswith failed - try an 'in' match for key in subtopic_map: if key and subtopic_query in key: subtopic_query = key fuzzy_match = True break if not fuzzy_match: # no match found - give up checked_topic = topic + f"/{subtopic_query}" output = self.format_help_entry( topic=topic, help_text=f"No help entry found for '{checked_topic}'", subtopics=subtopic_index ) self.msg_help(output) return # if we get here we have an exact or fuzzy match subtopic_map = subtopic_map.pop(subtopic_query) subtopic_index = [subtopic for subtopic in subtopic_map if subtopic is not None] # keep stepping down into the tree, append path to show position topic = topic + f"/{subtopic_query}" # we reached the bottom of the topic tree help_text = subtopic_map[None] output = self.format_help_entry( topic=topic, help_text=help_text, aliases=aliases if not subtopics else None, subtopics=subtopic_index, suggested=suggested ) self.msg_help(output)
def _loadhelp(caller): entry = caller.db._editing_help if entry: return entry.entrytext else: return "" def _savehelp(caller, buffer): entry = caller.db._editing_help caller.msg("Saved help entry.") if entry: entry.entrytext = buffer def _quithelp(caller): caller.msg("Closing the editor.") del caller.db._editing_help
[docs]class CmdSetHelp(COMMAND_DEFAULT_CLASS): """ Edit the help database. Usage: help[/switches] <topic>[[;alias;alias][,category[,locks]] [= <text>] Switches: edit - open a line editor to edit the topic's help text. replace - overwrite existing help topic. append - add text to the end of existing topic with a newline between. extend - as append, but don't add a newline. delete - remove help topic. Examples: sethelp lore = In the beginning was ... sethelp/append pickpocketing,Thievery = This steals ... sethelp/replace pickpocketing, ,attr(is_thief) = This steals ... sethelp/edit thievery If not assigning a category, the `settings.DEFAULT_HELP_CATEGORY` category will be used. If no lockstring is specified, everyone will be able to read the help entry. Sub-topics are embedded in the help text. Note that this cannot modify command-help entries - these are modified in-code, outside the game. # SUBTOPICS ## Adding subtopics Subtopics helps to break up a long help entry into sub-sections. Users can access subtopics with |whelp topic/subtopic/...|n Subtopics are created and stored together with the main topic. To start adding subtopics, add the text '# SUBTOPICS' on a new line at the end of your help text. After this you can now add any number of subtopics, each starting with '## <subtopic-name>' on a line, followed by the help-text of that subtopic. Use '### <subsub-name>' to add a sub-subtopic and so on. Max depth is 5. A subtopic's title is case-insensitive and can consist of multiple words - the user will be able to enter a partial match to access it. For example: | Main help text for <topic> | | # SUBTOPICS | | ## about | | Text for the '<topic>/about' subtopic' | | ### more about-info | | Text for the '<topic>/about/more about-info sub-subtopic | | ## extra | | Text for the '<topic>/extra' subtopic """ key = "sethelp" switch_options = ("edit", "replace", "append", "extend", "delete") locks = "cmd:perm(Helper)" help_category = "Building"
[docs] def func(self): """Implement the function""" switches = self.switches lhslist = self.lhslist if not self.args: self.msg( "Usage: sethelp[/switches] <topic>[;alias;alias][,category[,locks,..] = <text>" ) return nlist = len(lhslist) topicstr = lhslist[0] if nlist > 0 else "" if not topicstr: self.msg("You have to define a topic!") return topicstrlist = topicstr.split(";") topicstr, aliases = ( topicstrlist[0], topicstrlist[1:] if len(topicstr) > 1 else [], ) aliastxt = ("(aliases: %s)" % ", ".join(aliases)) if aliases else "" old_entry = None # check if we have an old entry with the same name try: for querystr in topicstrlist: old_entry = HelpEntry.objects.find_topicmatch(querystr) # also search by alias if old_entry: old_entry = list(old_entry)[0] break category = lhslist[1] if nlist > 1 else old_entry.help_category lockstring = ",".join(lhslist[2:]) if nlist > 2 else old_entry.locks.get() except Exception: old_entry = None category = lhslist[1] if nlist > 1 else DEFAULT_HELP_CATEGORY lockstring = ",".join(lhslist[2:]) if nlist > 2 else "view:all()" category = category.lower() if "edit" in switches: # open the line editor to edit the helptext. No = is needed. if old_entry: topicstr = old_entry.key if self.rhs: # we assume append here. old_entry.entrytext += "\n%s" % self.rhs helpentry = old_entry else: helpentry = create.create_help_entry( topicstr, self.rhs, category=category, locks=lockstring, aliases=aliases, ) self.caller.db._editing_help = helpentry EvEditor( self.caller, loadfunc=_loadhelp, savefunc=_savehelp, quitfunc=_quithelp, key="topic {}".format(topicstr), persistent=True, ) return if "append" in switches or "merge" in switches or "extend" in switches: # merge/append operations if not old_entry: self.msg("Could not find topic '%s'. You must give an exact name." % topicstr) return if not self.rhs: self.msg("You must supply text to append/merge.") return if "merge" in switches: old_entry.entrytext += " " + self.rhs else: old_entry.entrytext += "\n%s" % self.rhs old_entry.aliases.add(aliases) self.msg("Entry updated:\n%s%s" % (old_entry.entrytext, aliastxt)) return if "delete" in switches or "del" in switches: # delete the help entry if not old_entry: self.msg("Could not find topic '%s'%s." % (topicstr, aliastxt)) return old_entry.delete() self.msg("Deleted help entry '%s'%s." % (topicstr, aliastxt)) return # at this point it means we want to add a new help entry. if not self.rhs: self.msg("You must supply a help text to add.") return if old_entry: if "replace" in switches: # overwrite old entry old_entry.key = topicstr old_entry.entrytext = self.rhs old_entry.help_category = category old_entry.locks.clear() old_entry.locks.add(lockstring) old_entry.aliases.add(aliases) old_entry.save() self.msg("Overwrote the old topic '%s'%s." % (topicstr, aliastxt)) else: self.msg( "Topic '%s'%s already exists. Use /replace to overwrite " "or /append or /merge to add text to it." % (topicstr, aliastxt) ) else: # no old entry. Create a new one. new_entry = create.create_help_entry( topicstr, self.rhs, category=category, locks=lockstring, aliases=aliases ) if new_entry: self.msg("Topic '%s'%s was successfully created." % (topicstr, aliastxt)) if "edit" in switches: # open the line editor to edit the helptext self.caller.db._editing_help = new_entry EvEditor( self.caller, loadfunc=_loadhelp, savefunc=_savehelp, quitfunc=_quithelp, key="topic {}".format(new_entry.key), persistent=True, ) return else: self.msg( "Error when creating topic '%s'%s! Contact an admin." % (topicstr, aliastxt) )