New FileHelp system to create help entries from external files
This commit is contained in:
parent
8a7e19db16
commit
f5fd398480
10 changed files with 432 additions and 290 deletions
|
|
@ -48,6 +48,7 @@
|
||||||
- `utils.evmenu.ask_yes_no` is a helper function that makes it easy to ask a yes/no question
|
- `utils.evmenu.ask_yes_no` is a helper function that makes it easy to ask a yes/no question
|
||||||
to the user and respond to their input. This complements the existing `get_input` helper.
|
to the user and respond to their input. This complements the existing `get_input` helper.
|
||||||
- Allow sending messages with `page/tell` without a `=` if target name contains no spaces.
|
- Allow sending messages with `page/tell` without a `=` if target name contains no spaces.
|
||||||
|
- New FileHelpStorage system allows adding help entries via external files.
|
||||||
|
|
||||||
### Evennia 0.9.5 (2019-2020)
|
### Evennia 0.9.5 (2019-2020)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -874,7 +874,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS):
|
||||||
caller,
|
caller,
|
||||||
f"Are you sure you want to delete channel '{channel.key}'"
|
f"Are you sure you want to delete channel '{channel.key}'"
|
||||||
"(make sure name is correct!)? This will disconnect and "
|
"(make sure name is correct!)? This will disconnect and "
|
||||||
"remove all users' aliases. {yesno}?",
|
"remove all users' aliases. {options}?",
|
||||||
_perform_delete,
|
_perform_delete,
|
||||||
"Aborted."
|
"Aborted."
|
||||||
)
|
)
|
||||||
|
|
@ -969,7 +969,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS):
|
||||||
caller,
|
caller,
|
||||||
f"Are you sure you want to boot user {target.key} from "
|
f"Are you sure you want to boot user {target.key} from "
|
||||||
f"channel(s) {channames} (make sure name/channels are correct{reasonwarn}). "
|
f"channel(s) {channames} (make sure name/channels are correct{reasonwarn}). "
|
||||||
"{yesno}?",
|
"{options}?",
|
||||||
_boot_user,
|
_boot_user,
|
||||||
"Aborted.",
|
"Aborted.",
|
||||||
default="Y"
|
default="Y"
|
||||||
|
|
@ -1022,7 +1022,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS):
|
||||||
caller,
|
caller,
|
||||||
f"Are you sure you want to ban user {target.key} from "
|
f"Are you sure you want to ban user {target.key} from "
|
||||||
f"channel(s) {channames} (make sure name/channels are correct{reasonwarn}) "
|
f"channel(s) {channames} (make sure name/channels are correct{reasonwarn}) "
|
||||||
"{yesno}?",
|
"{options}?",
|
||||||
_ban_user,
|
_ban_user,
|
||||||
"Aborted.",
|
"Aborted.",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,229 +8,53 @@ creation of other help topics such as RP help or game-world aides.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from evennia.utils.utils import fill, dedent
|
from evennia.utils.utils import fill, dedent
|
||||||
from evennia.help.models import HelpEntry
|
from evennia.help.models import HelpEntry
|
||||||
from evennia.utils import create, evmore
|
from evennia.utils import create, evmore
|
||||||
from evennia.utils.ansi import ANSIString
|
from evennia.utils.ansi import ANSIString
|
||||||
|
from evennia.help.filehelp import FILE_HELP_ENTRIES
|
||||||
from evennia.utils.eveditor import EvEditor
|
from evennia.utils.eveditor import EvEditor
|
||||||
from evennia.utils.utils import (
|
from evennia.utils.utils import (
|
||||||
class_from_module,
|
class_from_module,
|
||||||
inherits_from,
|
inherits_from,
|
||||||
format_grid, pad
|
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)
|
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
|
||||||
HELP_MORE = settings.HELP_MORE
|
HELP_MORE = settings.HELP_MORE
|
||||||
CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES
|
CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES
|
||||||
|
|
||||||
_RE_HELP_SUBTOPICS_START = re.compile(
|
|
||||||
r"^\s*?#\s*?subtopics\s*?$", re.I + re.M)
|
|
||||||
_RE_HELP_SUBTOPIC_SPLIT = re.compile(r"^\s*?(\#{2,6}\s*?\w+?[a-z0-9 \-\?!,\.]*?)$", re.M + re.I)
|
|
||||||
_RE_HELP_SUBTOPIC_PARSE = re.compile(
|
|
||||||
r"^(?P<nesting>\#{2,6})\s*?(?P<name>.*?)$", re.I + re.M)
|
|
||||||
MAX_SUBTOPIC_NESTING = 5
|
|
||||||
|
|
||||||
|
|
||||||
# limit symbol import for API
|
# limit symbol import for API
|
||||||
__all__ = ("CmdHelp", "CmdSetHelp")
|
__all__ = ("CmdHelp", "CmdSetHelp")
|
||||||
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
|
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
|
||||||
_SEP = "|C" + "-" * _DEFAULT_WIDTH + "|n"
|
_SEP = "|C" + "-" * _DEFAULT_WIDTH + "|n"
|
||||||
|
|
||||||
_LUNR = None
|
|
||||||
_LUNR_EXCEPTION = None
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
class HelpCategory:
|
class HelpCategory:
|
||||||
def __init__(self, key):
|
"""
|
||||||
self.key = key
|
Mock 'help entry' to search categories with the same code.
|
||||||
|
|
||||||
|
"""
|
||||||
|
key: str
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def search_index_entry(self):
|
def search_index_entry(self):
|
||||||
return {
|
return {
|
||||||
"key": str(self),
|
"key": self.key,
|
||||||
"aliases": "",
|
"aliases": "",
|
||||||
"category": self.key,
|
"category": self.key,
|
||||||
"tags": "",
|
"tags": "",
|
||||||
"text": "",
|
"text": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Category: {self.key}"
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return str(self).lower() == str(other).lower()
|
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
return id(self)
|
return hash(id(self))
|
||||||
|
|
||||||
|
|
||||||
def help_search_with_index(query, candidate_entries, suggestion_maxnum=5):
|
|
||||||
"""
|
|
||||||
Lunr-powered fast index search and suggestion wrapper
|
|
||||||
"""
|
|
||||||
global _LUNR, _LUNR_EXCEPTION
|
|
||||||
if not _LUNR:
|
|
||||||
# we have to delay-load lunr because it messes with logging if it's imported
|
|
||||||
# before twisted's logging has been set up
|
|
||||||
from lunr import lunr as _LUNR
|
|
||||||
from lunr.exceptions import QueryParseError as _LUNR_EXCEPTION
|
|
||||||
|
|
||||||
indx = [cnd.search_index_entry for cnd in candidate_entries]
|
|
||||||
mapping = {indx[ix]["key"]: cand for ix, cand in enumerate(candidate_entries)}
|
|
||||||
|
|
||||||
search_index = _LUNR(
|
|
||||||
ref="key",
|
|
||||||
fields=[
|
|
||||||
{"field_name": "key", "boost": 10},
|
|
||||||
{"field_name": "aliases", "boost": 9},
|
|
||||||
{"field_name": "category", "boost": 8},
|
|
||||||
{"field_name": "tags", "boost": 5},
|
|
||||||
{"field_name": "text", "boost": 1},
|
|
||||||
],
|
|
||||||
documents=indx,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
matches = search_index.search(query)[:suggestion_maxnum]
|
|
||||||
except _LUNR_EXCEPTION:
|
|
||||||
# this is a user-input problem
|
|
||||||
matches = []
|
|
||||||
|
|
||||||
# matches (objs), suggestions (strs)
|
|
||||||
return (
|
|
||||||
[mapping[match["ref"]] for match in matches],
|
|
||||||
[str(match["ref"]) for match in matches], # + f" (score {match['score']})") # good debug
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_entry_for_subcategories(entry):
|
|
||||||
"""
|
|
||||||
Parse a command docstring for special sub-category blocks:
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entry (str): A help entry to parse
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: The dict is a mapping that splits the entry into subcategories. This
|
|
||||||
will always hold a key `None` for the main help entry and
|
|
||||||
zero or more keys holding the subcategories. Each is itself
|
|
||||||
a dict with a key `None` for the main text of that subcategory
|
|
||||||
followed by any sub-sub-categories down to a max-depth of 5.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
::
|
|
||||||
|
|
||||||
'''
|
|
||||||
Main topic text
|
|
||||||
|
|
||||||
# SUBTOPICS
|
|
||||||
|
|
||||||
## foo
|
|
||||||
|
|
||||||
A subcategory of the main entry, accessible as `help topic foo`
|
|
||||||
(or using /, like `help topic/foo`)
|
|
||||||
|
|
||||||
## bar
|
|
||||||
|
|
||||||
Another subcategory, accessed as `help topic bar`
|
|
||||||
(or `help topic/bar`)
|
|
||||||
|
|
||||||
### moo
|
|
||||||
|
|
||||||
A subcategory of bar, accessed as `help bar moo`
|
|
||||||
(or `help bar/moo`)
|
|
||||||
|
|
||||||
#### dum
|
|
||||||
|
|
||||||
A subcategory of moo, accessed `help bar moo dum`
|
|
||||||
(or `help bar/moo/dum`)
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
This will result in this returned entry structure:
|
|
||||||
::
|
|
||||||
|
|
||||||
{
|
|
||||||
None: "Main topic text":
|
|
||||||
"foo": {
|
|
||||||
None: "main topic/foo text"
|
|
||||||
},
|
|
||||||
"bar": {
|
|
||||||
None: "Main topic/bar text",
|
|
||||||
"moo": {
|
|
||||||
None: "topic/bar/moo text"
|
|
||||||
"dum": {
|
|
||||||
None: "topic/bar/moo/dum text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Apart from making
|
|
||||||
sub-categories at the bottom of the entry.
|
|
||||||
|
|
||||||
This will be applied both to command docstrings and database-based help
|
|
||||||
entries.
|
|
||||||
|
|
||||||
"""
|
|
||||||
topic, *subtopics = _RE_HELP_SUBTOPICS_START.split(entry, maxsplit=1)
|
|
||||||
structure = {None: topic.strip()}
|
|
||||||
|
|
||||||
if subtopics:
|
|
||||||
subtopics = subtopics[0]
|
|
||||||
else:
|
|
||||||
return structure
|
|
||||||
|
|
||||||
keypath = []
|
|
||||||
current_nesting = 0
|
|
||||||
subtopic = None
|
|
||||||
|
|
||||||
# from evennia import set_trace;set_trace()
|
|
||||||
for part in _RE_HELP_SUBTOPIC_SPLIT.split(subtopics.strip()):
|
|
||||||
|
|
||||||
subtopic_match = _RE_HELP_SUBTOPIC_PARSE.match(part.strip())
|
|
||||||
if subtopic_match:
|
|
||||||
# a new sub(-sub..) category starts.
|
|
||||||
mdict = subtopic_match.groupdict()
|
|
||||||
subtopic = mdict['name'].lower().strip()
|
|
||||||
new_nesting = len(mdict['nesting']) - 1
|
|
||||||
|
|
||||||
if new_nesting > MAX_SUBTOPIC_NESTING:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Can have max {MAX_SUBTOPIC_NESTING} levels of nested help subtopics.")
|
|
||||||
|
|
||||||
nestdiff = new_nesting - current_nesting
|
|
||||||
if nestdiff < 0:
|
|
||||||
# jumping back up in nesting
|
|
||||||
for _ in range(abs(nestdiff) + 1):
|
|
||||||
try:
|
|
||||||
keypath.pop()
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
elif nestdiff == 0:
|
|
||||||
# don't add a deeper nesting but replace the current
|
|
||||||
try:
|
|
||||||
keypath.pop()
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
keypath.append(subtopic)
|
|
||||||
current_nesting = new_nesting
|
|
||||||
else:
|
|
||||||
# an entry belonging to a subtopic - find the nested location
|
|
||||||
dct = structure
|
|
||||||
if not keypath and subtopic is not None:
|
|
||||||
structure[subtopic] = dedent(part.strip())
|
|
||||||
else:
|
|
||||||
for key in keypath:
|
|
||||||
if key in dct:
|
|
||||||
dct = dct[key]
|
|
||||||
else:
|
|
||||||
dct[key] = {
|
|
||||||
None: dedent(part.strip())
|
|
||||||
}
|
|
||||||
return structure
|
|
||||||
|
|
||||||
|
|
||||||
class CmdHelp(COMMAND_DEFAULT_CLASS):
|
class CmdHelp(COMMAND_DEFAULT_CLASS):
|
||||||
|
|
@ -329,7 +153,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
|
||||||
else:
|
else:
|
||||||
aliases = ''
|
aliases = ''
|
||||||
|
|
||||||
help_text = f"\n\n{dedent(' ' + help_text.strip())}\n" if help_text else ""
|
help_text = "\n\n" + dedent(help_text.strip(), indent=0) + "\n" if help_text else ""
|
||||||
|
|
||||||
if subtopics:
|
if subtopics:
|
||||||
subtopics = (
|
subtopics = (
|
||||||
|
|
@ -503,11 +327,17 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
|
||||||
# having to allow doublet commands to manage exits etc.
|
# having to allow doublet commands to manage exits etc.
|
||||||
cmdset.make_unique(caller)
|
cmdset.make_unique(caller)
|
||||||
|
|
||||||
# retrieve all available commands and database topics
|
# retrieve all available commands and database / file-help topics
|
||||||
all_cmds = [cmd for cmd in cmdset if self.check_show_help(cmd, caller)]
|
all_cmds = [cmd for cmd in cmdset if self.check_show_help(cmd, caller)]
|
||||||
all_db_topics = [
|
|
||||||
topic for topic in HelpEntry.objects.all() if topic.access(caller, "view", default=True)
|
# 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(
|
all_categories = list(set(
|
||||||
[HelpCategory(cmd.help_category) for cmd in all_cmds]
|
[HelpCategory(cmd.help_category) for cmd in all_cmds]
|
||||||
+ [HelpCategory(topic.help_category) for topic in all_db_topics]
|
+ [HelpCategory(topic.help_category) for topic in all_db_topics]
|
||||||
|
|
@ -539,7 +369,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
|
||||||
# Lunr search engine
|
# Lunr search engine
|
||||||
|
|
||||||
# all available options
|
# all available options
|
||||||
entries = [cmd for cmd in all_cmds if cmd] + list(HelpEntry.objects.all()) + all_categories
|
entries = [cmd for cmd in all_cmds if cmd] + all_db_topics + all_categories
|
||||||
match, suggestions = None, None
|
match, suggestions = None, None
|
||||||
|
|
||||||
for match_query in [f"{query}~1", f"{query}*"]:
|
for match_query in [f"{query}~1", f"{query}*"]:
|
||||||
|
|
@ -590,10 +420,10 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
|
||||||
aliases = match.aliases
|
aliases = match.aliases
|
||||||
suggested = suggestions[1:]
|
suggested = suggestions[1:]
|
||||||
else:
|
else:
|
||||||
# a database match
|
# a database (or file-help) match
|
||||||
topic = match.key
|
topic = match.key
|
||||||
help_text = match.entrytext
|
help_text = match.entrytext
|
||||||
aliases = match.aliases.all()
|
aliases = match.aliases if isinstance(match.aliases, list) else match.aliases.all()
|
||||||
suggested = suggestions[1:]
|
suggested = suggestions[1:]
|
||||||
|
|
||||||
# parse for subtopics. The subtopic_map is a dict with the current topic/subtopic
|
# parse for subtopics. The subtopic_map is a dict with the current topic/subtopic
|
||||||
|
|
|
||||||
|
|
@ -424,7 +424,7 @@ class TestHelp(CommandTest):
|
||||||
logging.disable(level=logging.ERROR)
|
logging.disable(level=logging.ERROR)
|
||||||
|
|
||||||
def test_help(self):
|
def test_help(self):
|
||||||
self.call(help_module.CmdHelp(), "", "Admin", cmdset=CharacterCmdSet())
|
self.call(help_module.CmdHelp(), "", "Commands", cmdset=CharacterCmdSet())
|
||||||
|
|
||||||
def test_set_help(self):
|
def test_set_help(self):
|
||||||
self.call(
|
self.call(
|
||||||
|
|
@ -434,85 +434,6 @@ class TestHelp(CommandTest):
|
||||||
)
|
)
|
||||||
self.call(help_module.CmdHelp(), "testhelp", "Help for testhelp", cmdset=CharacterCmdSet())
|
self.call(help_module.CmdHelp(), "testhelp", "Help for testhelp", cmdset=CharacterCmdSet())
|
||||||
|
|
||||||
def test_parse_entry(self):
|
|
||||||
"""
|
|
||||||
Test for subcategories
|
|
||||||
|
|
||||||
"""
|
|
||||||
entry = """
|
|
||||||
Main topic text
|
|
||||||
|
|
||||||
# subtopics
|
|
||||||
|
|
||||||
## foo
|
|
||||||
|
|
||||||
Foo sub-category
|
|
||||||
|
|
||||||
### moo
|
|
||||||
|
|
||||||
Foo/Moo subsub-category
|
|
||||||
|
|
||||||
#### dum
|
|
||||||
|
|
||||||
Foo/Moo/Dum subsubsub-category
|
|
||||||
|
|
||||||
## bar
|
|
||||||
|
|
||||||
Bar subcategory
|
|
||||||
|
|
||||||
### moo
|
|
||||||
|
|
||||||
Bar/Moo subcategory
|
|
||||||
|
|
||||||
"""
|
|
||||||
expected = {
|
|
||||||
None: "Main topic text",
|
|
||||||
"foo": {
|
|
||||||
None: "Foo sub-category",
|
|
||||||
"moo": {
|
|
||||||
None: "Foo/Moo subsub-category",
|
|
||||||
"dum": {
|
|
||||||
None: "Foo/Moo/Dum subsubsub-category",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"bar": {
|
|
||||||
None: "Bar subcategory",
|
|
||||||
"moo": {
|
|
||||||
None: "Bar/Moo subcategory"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual_result = help_module.parse_entry_for_subcategories(entry)
|
|
||||||
self.assertEqual(expected, actual_result)
|
|
||||||
|
|
||||||
def test_parse_single_entry(self):
|
|
||||||
"""
|
|
||||||
Test parsing single subcategory
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
entry = """
|
|
||||||
Main topic text
|
|
||||||
|
|
||||||
# SUBTOPICS
|
|
||||||
|
|
||||||
## creating extra stuff
|
|
||||||
|
|
||||||
Help on creating extra stuff.
|
|
||||||
|
|
||||||
"""
|
|
||||||
expected = {
|
|
||||||
None: "Main topic text",
|
|
||||||
"creating extra stuff": {
|
|
||||||
None: "Help on creating extra stuff."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual_result = help_module.parse_entry_for_subcategories(entry)
|
|
||||||
self.assertEqual(expected, actual_result)
|
|
||||||
|
|
||||||
@parameterized.expand([
|
@parameterized.expand([
|
||||||
("test", # main help entry
|
("test", # main help entry
|
||||||
"Help for test\n\n"
|
"Help for test\n\n"
|
||||||
|
|
|
||||||
60
evennia/game_template/world/help_entries.py
Normal file
60
evennia/game_template/world/help_entries.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""
|
||||||
|
File-based help entries. These complements command-based help and help entries
|
||||||
|
added in the database using the `sethelp` command in-game.
|
||||||
|
|
||||||
|
Control where Evennia reads these entries with `settings.FILE_HELP_ENTRY_MODULES`,
|
||||||
|
which is a list of python-paths to modules to read.
|
||||||
|
|
||||||
|
A module like this should hold a global `HELP_ENTRY_DICTS` list, containing
|
||||||
|
dicts that each represent a help entry. If no `HELP_ENTRY_DICTS` variable is
|
||||||
|
given, all top-level variables that are dicts in the module are read as help
|
||||||
|
entries.
|
||||||
|
|
||||||
|
Each dict is on the form
|
||||||
|
::
|
||||||
|
|
||||||
|
{'key': <str>,
|
||||||
|
'category': <str>, # optional, otherwise settings.FILE_DEFAULT_HELP_CATEGORY
|
||||||
|
'aliases': <list>, # optional
|
||||||
|
'text': <str>}`` # the actual help text. Can contain # subtopic sections
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
HELP_ENTRY_DICTS = [
|
||||||
|
{
|
||||||
|
"key": "evennia",
|
||||||
|
"aliases": ['ev'],
|
||||||
|
"category": "General",
|
||||||
|
"text": """
|
||||||
|
Evennia is a MUD game server in Python.
|
||||||
|
|
||||||
|
# subtopics
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
You'll find installation instructions on https:evennia.com
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
There are many ways to get help and communicate with other devs!
|
||||||
|
|
||||||
|
### IRC
|
||||||
|
|
||||||
|
The irc channel is #evennia on irc.freenode.net
|
||||||
|
|
||||||
|
### Discord
|
||||||
|
|
||||||
|
There is also a discord channel you can find from the sidebard on evennia.com.
|
||||||
|
|
||||||
|
"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "building",
|
||||||
|
"category": "building",
|
||||||
|
"text": """
|
||||||
|
Evennia comes with a bunch of default building commands. You can
|
||||||
|
find a building tutorial in the evennia documentation.
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
]
|
||||||
178
evennia/help/filehelp.py
Normal file
178
evennia/help/filehelp.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""
|
||||||
|
The filehelp-system allows for defining help files outside of the game. These
|
||||||
|
will be treated as non-command help entries and displayed in the same way as
|
||||||
|
help entries created using the `sethelp` default command. After changing an
|
||||||
|
entry on-disk you need to reload the server to have the change show in-game.
|
||||||
|
|
||||||
|
An filehelp file is a regular python modules with dicts representing each help
|
||||||
|
entry. If a list `HELP_ENTRY_DICTS` is found in the module, this should be a list of
|
||||||
|
dicts. Otherwise *all* top-level dicts in the module will be assumed to be a
|
||||||
|
help-entry dict.
|
||||||
|
|
||||||
|
Each help-entry dict is on the form
|
||||||
|
::
|
||||||
|
|
||||||
|
{'key': <str>,
|
||||||
|
'category': <str>, # optional, otherwise settings.FILE_DEFAULT_HELP_CATEGORY
|
||||||
|
'aliases': <list>, # optional
|
||||||
|
'text': <str>}``
|
||||||
|
|
||||||
|
where the ``category`` is optional and the ``text`` should be formatted on the
|
||||||
|
same form as other help entry-texts and contain ``# subtopics`` as normal.
|
||||||
|
|
||||||
|
New help-entry modules are added to the system by providing the python-path to
|
||||||
|
the module to `settings.FILE_HELP_ENTRY_MODULES`. Note that if same-key entries are
|
||||||
|
added, entries in latter modules will override that of earlier ones. Use
|
||||||
|
``settings.FILE_DEFAULT_HELP_CATEGORY`` to customize what category is used if
|
||||||
|
not set explicitly.
|
||||||
|
|
||||||
|
An example of the contents of a module:
|
||||||
|
::
|
||||||
|
|
||||||
|
help_entry1 = {
|
||||||
|
"key": "The Gods", # case-insensitive, can be searched by 'gods' as well
|
||||||
|
"aliases": ['pantheon', 'religion']
|
||||||
|
"category": "Lore",
|
||||||
|
"text": '''
|
||||||
|
The gods formed the world ...
|
||||||
|
|
||||||
|
# Subtopics
|
||||||
|
|
||||||
|
## Pantheon
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
### God of love
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
### God of war
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
HELP_ENTRY_DICTS = [
|
||||||
|
help_entry1,
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from django.conf import settings
|
||||||
|
from evennia.utils.utils import (
|
||||||
|
variable_from_module, make_iter, all_from_module)
|
||||||
|
from evennia.utils import logger
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileHelpEntry:
|
||||||
|
"""
|
||||||
|
Represents a help entry read from file. This mimics the api of the
|
||||||
|
database-bound HelpEntry so that they can be used interchangeably in the
|
||||||
|
help command.
|
||||||
|
|
||||||
|
"""
|
||||||
|
key: str
|
||||||
|
aliases: list
|
||||||
|
help_category: str
|
||||||
|
entrytext: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def search_index_entry(self):
|
||||||
|
"""
|
||||||
|
Property for easily retaining a search index entry for this object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"aliases": " ".join(self.aliases),
|
||||||
|
"category": self.help_category,
|
||||||
|
"tags": "",
|
||||||
|
"text": self.entrytext,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class FileHelpStorageHandler:
|
||||||
|
"""
|
||||||
|
This reads and stores help entries for quick access. By default
|
||||||
|
it reads modules from `settings.FILE_HELP_ENTRY_MODULES`.
|
||||||
|
|
||||||
|
Note that this is not meant to any searching/lookup - that is all handled
|
||||||
|
by the help command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, help_file_modules=settings.FILE_HELP_ENTRY_MODULES):
|
||||||
|
"""
|
||||||
|
Initialize the storage.
|
||||||
|
"""
|
||||||
|
self.help_file_modules = [str(part).strip()
|
||||||
|
for part in make_iter(help_file_modules)]
|
||||||
|
self.help_entries = []
|
||||||
|
self.help_entries_dict = {}
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
"""
|
||||||
|
Load/reload file-based help-entries from file.
|
||||||
|
|
||||||
|
"""
|
||||||
|
loaded_help_dicts = []
|
||||||
|
|
||||||
|
for module_or_path in self.help_file_modules:
|
||||||
|
help_dict_list = variable_from_module(
|
||||||
|
module_or_path, variable="HELP_ENTRY_DICTS"
|
||||||
|
)
|
||||||
|
if not help_dict_list:
|
||||||
|
help_dict_list = [
|
||||||
|
dct for dct in all_from_module(module_or_path).values()
|
||||||
|
if isinstance(dct, dict)]
|
||||||
|
if help_dict_list:
|
||||||
|
loaded_help_dicts.extend(help_dict_list)
|
||||||
|
else:
|
||||||
|
logger.log_err(f"Could not find file-help module {module_or_path} (skipping).")
|
||||||
|
|
||||||
|
# validate and parse dicts into FileEntryHelp objects and make sure they are unique-by-key
|
||||||
|
# by letting latter added ones override earlier ones.
|
||||||
|
unique_help_entries = {}
|
||||||
|
|
||||||
|
for dct in loaded_help_dicts:
|
||||||
|
key = dct.get('key').lower().strip()
|
||||||
|
category = dct.get('category', settings.FILE_DEFAULT_HELP_CATEGORY).strip()
|
||||||
|
aliases = list(dct.get('aliases', []))
|
||||||
|
entrytext = dct.get('text')
|
||||||
|
|
||||||
|
if not key and entrytext:
|
||||||
|
logger.error(f"Cannot load file-help-entry (missing key or text): {dct}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
unique_help_entries[key] = FileHelpEntry(
|
||||||
|
key=key, help_category=category, aliases=aliases,
|
||||||
|
entrytext=entrytext)
|
||||||
|
|
||||||
|
self.help_entries_dict = unique_help_entries
|
||||||
|
self.help_entries = list(unique_help_entries.values())
|
||||||
|
|
||||||
|
def all(self, return_dict=False):
|
||||||
|
"""
|
||||||
|
Get all help entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
return_dict (bool): Return a dict ``{key: FileHelpEntry,...}``. Otherwise,
|
||||||
|
return a list of ``FileHelpEntry`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict or list: Depending on the setting of ``return_dict``.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self.help_entries_dict if return_dict else self.help_entries
|
||||||
|
|
||||||
|
|
||||||
|
# singleton to hold the loaded help entries
|
||||||
|
FILE_HELP_ENTRIES = FileHelpStorageHandler()
|
||||||
145
evennia/help/tests.py
Normal file
145
evennia/help/tests.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""
|
||||||
|
Unittests for help code (The default help-command is tested as part of default
|
||||||
|
command test-suite).
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
from evennia.utils.test_resources import TestCase
|
||||||
|
from evennia.utils.utils import dedent
|
||||||
|
from evennia.help import filehelp, utils as help_utils
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseSubtopics(TestCase):
|
||||||
|
"""
|
||||||
|
Test the subtopic parser.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_parse_entry(self):
|
||||||
|
"""
|
||||||
|
Test for subcategories
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.maxDiff = None
|
||||||
|
|
||||||
|
entry = dedent("""
|
||||||
|
Main topic text
|
||||||
|
# subtopics
|
||||||
|
## foo
|
||||||
|
Foo sub-category
|
||||||
|
### moo
|
||||||
|
Foo/Moo subsub-category
|
||||||
|
#### dum
|
||||||
|
Foo/Moo/Dum subsubsub-category
|
||||||
|
## bar
|
||||||
|
Bar subcategory
|
||||||
|
### moo
|
||||||
|
Bar/Moo subcategory
|
||||||
|
""", indent=0)
|
||||||
|
expected = {
|
||||||
|
None: "Main topic text",
|
||||||
|
"foo": {
|
||||||
|
None: "\nFoo sub-category\n",
|
||||||
|
"moo": {
|
||||||
|
None: "\nFoo/Moo subsub-category\n",
|
||||||
|
"dum": {
|
||||||
|
None: "\nFoo/Moo/Dum subsubsub-category\n",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"bar": {
|
||||||
|
None: "\nBar subcategory\n",
|
||||||
|
"moo": {
|
||||||
|
None: "\nBar/Moo subcategory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual_result = help_utils.parse_entry_for_subcategories(entry)
|
||||||
|
self.assertEqual(expected, actual_result)
|
||||||
|
|
||||||
|
def test_parse_single_entry(self):
|
||||||
|
"""
|
||||||
|
Test parsing single subcategory
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
entry = dedent("""
|
||||||
|
Main topic text
|
||||||
|
# SUBTOPICS
|
||||||
|
## creating extra stuff
|
||||||
|
Help on creating extra stuff.
|
||||||
|
""", indent=0)
|
||||||
|
expected = {
|
||||||
|
None: "Main topic text",
|
||||||
|
"creating extra stuff": {
|
||||||
|
None: "\nHelp on creating extra stuff."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual_result = help_utils.parse_entry_for_subcategories(entry)
|
||||||
|
self.assertEqual(expected, actual_result)
|
||||||
|
|
||||||
|
# test filehelp system
|
||||||
|
|
||||||
|
HELP_ENTRY_DICTS = [
|
||||||
|
{
|
||||||
|
"key": "evennia",
|
||||||
|
"aliases": ['ev'],
|
||||||
|
"category": "General",
|
||||||
|
"text": """
|
||||||
|
Evennia is a MUD game server in Python.
|
||||||
|
|
||||||
|
# subtopics
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
You'll find installation instructions on https:evennia.com
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
There are many ways to get help and communicate with other devs!
|
||||||
|
|
||||||
|
### IRC
|
||||||
|
|
||||||
|
The irc channel is #evennia on irc.freenode.net
|
||||||
|
|
||||||
|
### Discord
|
||||||
|
|
||||||
|
There is also a discord channel you can find from the sidebard on evennia.com.
|
||||||
|
|
||||||
|
"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "building",
|
||||||
|
"category": "building",
|
||||||
|
"text": """
|
||||||
|
Evennia comes with a bunch of default building commands. You can
|
||||||
|
find a building tutorial in the evennia documentation.
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileHelp(TestCase):
|
||||||
|
"""
|
||||||
|
Test the File-help system
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
@mock.patch("evennia.help.filehelp.variable_from_module")
|
||||||
|
def test_file_help(self, mock_variable_from_module):
|
||||||
|
mock_variable_from_module.return_value = HELP_ENTRY_DICTS
|
||||||
|
|
||||||
|
# we just need anything here since we mock the load anyway
|
||||||
|
storage = filehelp.FileHelpStorageHandler(help_file_modules=["dummypath"])
|
||||||
|
result = storage.all()
|
||||||
|
|
||||||
|
for inum, helpentry in enumerate(result):
|
||||||
|
self.assertEqual(HELP_ENTRY_DICTS[inum]['key'], helpentry.key)
|
||||||
|
self.assertEqual(HELP_ENTRY_DICTS[inum].get('aliases', []), helpentry.aliases)
|
||||||
|
self.assertEqual(HELP_ENTRY_DICTS[inum]['category'], helpentry.help_category)
|
||||||
|
self.assertEqual(HELP_ENTRY_DICTS[inum]['text'], helpentry.entrytext)
|
||||||
|
|
@ -143,9 +143,6 @@ def _server_maintenance():
|
||||||
if _MAINTENANCE_COUNT % 5 == 0:
|
if _MAINTENANCE_COUNT % 5 == 0:
|
||||||
# check cache size every 5 minutes
|
# check cache size every 5 minutes
|
||||||
_FLUSH_CACHE(_IDMAPPER_CACHE_MAXSIZE)
|
_FLUSH_CACHE(_IDMAPPER_CACHE_MAXSIZE)
|
||||||
if _MAINTENANCE_COUNT % 60 == 0:
|
|
||||||
# validate scripts every hour
|
|
||||||
evennia.ScriptDB.objects.validate()
|
|
||||||
if _MAINTENANCE_COUNT % 61 == 0:
|
if _MAINTENANCE_COUNT % 61 == 0:
|
||||||
# validate channels off-sync with scripts
|
# validate channels off-sync with scripts
|
||||||
evennia.CHANNEL_HANDLER.update()
|
evennia.CHANNEL_HANDLER.update()
|
||||||
|
|
|
||||||
|
|
@ -448,8 +448,6 @@ COMMAND_DEFAULT_ARG_REGEX = None
|
||||||
# calling the Command. This may be more intuitive for users in certain
|
# calling the Command. This may be more intuitive for users in certain
|
||||||
# multisession modes.
|
# multisession modes.
|
||||||
COMMAND_DEFAULT_MSG_ALL_SESSIONS = False
|
COMMAND_DEFAULT_MSG_ALL_SESSIONS = False
|
||||||
# The help category of a command if not otherwise specified.
|
|
||||||
COMMAND_DEFAULT_HELP_CATEGORY = "general"
|
|
||||||
# The default lockstring of a command.
|
# The default lockstring of a command.
|
||||||
COMMAND_DEFAULT_LOCKS = ""
|
COMMAND_DEFAULT_LOCKS = ""
|
||||||
# The Channel Handler is responsible for managing all available channels. By
|
# The Channel Handler is responsible for managing all available channels. By
|
||||||
|
|
@ -597,6 +595,21 @@ TIME_GAME_EPOCH = None
|
||||||
# the real time (add a different epoch to shift time)
|
# the real time (add a different epoch to shift time)
|
||||||
TIME_IGNORE_DOWNTIMES = False
|
TIME_IGNORE_DOWNTIMES = False
|
||||||
|
|
||||||
|
######################################################################
|
||||||
|
# Help system
|
||||||
|
######################################################################
|
||||||
|
# Help output from CmdHelp are wrapped in an EvMore call
|
||||||
|
# (excluding webclient with separate help popups). If continuous scroll
|
||||||
|
# is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT
|
||||||
|
HELP_MORE = True
|
||||||
|
# The help category of a command if not specified.
|
||||||
|
COMMAND_DEFAULT_HELP_CATEGORY = "general"
|
||||||
|
# The help category of a file-based help entry if not specified
|
||||||
|
FILE_DEFAULT_HELP_CATEGORY = "general"
|
||||||
|
# File-based help entries. These are modules containing dicts defining help
|
||||||
|
# entries. They can be used together with in-database entries created in-game.
|
||||||
|
FILE_HELP_ENTRY_MODULES = ["world.help_entries"]
|
||||||
|
|
||||||
######################################################################
|
######################################################################
|
||||||
# FuncParser
|
# FuncParser
|
||||||
#
|
#
|
||||||
|
|
@ -679,10 +692,6 @@ PERMISSION_ACCOUNT_DEFAULT = "Player"
|
||||||
CLIENT_DEFAULT_WIDTH = 78
|
CLIENT_DEFAULT_WIDTH = 78
|
||||||
# telnet standard height is 24; does anyone use such low-res displays anymore?
|
# telnet standard height is 24; does anyone use such low-res displays anymore?
|
||||||
CLIENT_DEFAULT_HEIGHT = 45
|
CLIENT_DEFAULT_HEIGHT = 45
|
||||||
# Help output from CmdHelp are wrapped in an EvMore call
|
|
||||||
# (excluding webclient with separate help popups). If continuous scroll
|
|
||||||
# is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT
|
|
||||||
HELP_MORE = True
|
|
||||||
# Set rate limits per-IP on account creations and login attempts
|
# Set rate limits per-IP on account creations and login attempts
|
||||||
CREATION_THROTTLE_LIMIT = 2
|
CREATION_THROTTLE_LIMIT = 2
|
||||||
CREATION_THROTTLE_TIMEOUT = 10 * 60
|
CREATION_THROTTLE_TIMEOUT = 10 * 60
|
||||||
|
|
|
||||||
|
|
@ -1335,18 +1335,19 @@ def all_from_module(module):
|
||||||
already imported module object (e.g. `models`)
|
already imported module object (e.g. `models`)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
variables (dict): A dict of {variablename: variable} for all
|
dict: A dict of {variablename: variable} for all
|
||||||
variables in the given module.
|
variables in the given module.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
Ignores modules and variable names starting with an underscore.
|
Ignores modules and variable names starting with an underscore, as well
|
||||||
|
as variables imported into the module from other modules.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
mod = mod_import(module)
|
mod = mod_import(module)
|
||||||
if not mod:
|
if not mod:
|
||||||
return {}
|
return {}
|
||||||
# make sure to only return variables actually defined in this
|
# make sure to only return variables actually defined in this
|
||||||
# module if available (try to avoid not imports)
|
# module if available (try to avoid imports)
|
||||||
members = getmembers(mod, predicate=lambda obj: getmodule(obj) in (mod, None))
|
members = getmembers(mod, predicate=lambda obj: getmodule(obj) in (mod, None))
|
||||||
return dict((key, val) for key, val in members if not key.startswith("_"))
|
return dict((key, val) for key, val in members if not key.startswith("_"))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue