Rename "initial" state to "telopt_negotiation"
[mudpy.git] / mudpy / misc.py
index 2923feb..3cd8e64 100644 (file)
@@ -1,6 +1,6 @@
 """Miscellaneous functions for the mudpy engine."""
 
-# Copyright (c) 2004-2016 Jeremy Stanley <fungi@yuggoth.org>. Permission
+# Copyright (c) 2004-2018 Jeremy Stanley <fungi@yuggoth.org>. Permission
 # to use, copy, modify, and distribute this software is granted under
 # terms provided in the LICENSE file distributed with this software.
 
@@ -23,91 +23,61 @@ class Element:
 
     """An element of the universe."""
 
-    def __init__(self, key, universe, filename=None, old_style=False):
+    def __init__(self, key, universe, origin=None):
         """Set up a new element."""
 
-        # TODO(fungi): This can be removed after the transition is complete
-        self.old_style = old_style
-
         # keep track of our key name
         self.key = key
 
         # keep track of what universe it's loading into
         self.universe = universe
 
-        # clone attributes if this is replacing another element
-        if self.old_style and self.key in self.universe.contents:
-            old_element = self.universe.contents[self.key]
-            for attribute in vars(old_element).keys():
-                exec("self." + attribute + " = old_element." + attribute)
-            if self.owner:
-                self.owner.avatar = self
+        # set of facet keys from the universe
+        self.facethash = dict()
 
-        # i guess this is a new element then
-        else:
+        # not owned by a user by default (used for avatars)
+        self.owner = None
 
-            # set of facet keys from the universe
-            self.facethash = dict()
+        # no contents in here by default
+        self.contents = {}
 
-            # not owned by a user by default (used for avatars)
-            self.owner = None
+        if self.key.find(".") > 0:
+            self.group, self.subkey = self.key.split(".")[-2:]
+        else:
+            self.group = "other"
+            self.subkey = self.key
+        if self.group not in self.universe.groups:
+            self.universe.groups[self.group] = {}
 
-            # no contents in here by default
-            self.contents = {}
-
-            # parse out appropriate category and subkey names, add to list
-            if self.key.find(":") > 0:
-                self.category, self.subkey = self.key.split(":", 1)
-            else:
-                self.category = "other"
-                self.subkey = self.key
-            if self.category not in self.universe.categories:
-                self.category = "other"
-                self.subkey = self.key
-
-            # get an appropriate filename for the origin
-            if not filename:
-                filename = self.universe.default_origins[self.category]
-            if not os.path.isabs(filename):
-                filename = os.path.abspath(filename)
-
-            # add the file if it doesn't exist yet
-            if filename not in self.universe.files:
-                mudpy.data.DataFile(filename, self.universe)
+        # get an appropriate origin
+        if not origin:
+            self.universe.add_group(self.group)
+            origin = self.universe.files[
+                    self.universe.origins[self.group]["fallback"]]
 
         # record or reset a pointer to the origin file
-        self.origin = self.universe.files[filename]
-
-        # add a data section to the origin if necessary
-        if self.key not in self.origin.data:
-            self.origin.data[self.key] = {}
+        self.origin = self.universe.files[origin.source]
 
         # add or replace this element in the universe
         self.universe.contents[self.key] = self
-        self.universe.categories[self.category][self.subkey] = self
+        self.universe.groups[self.group][self.subkey] = self
 
     def reload(self):
         """Create a new element and replace this one."""
-        Element(self.key, self.universe, self.origin.filename,
-                old_style=self.old_style)
+        Element(self.key, self.universe, self.origin)
         del(self)
 
     def destroy(self):
         """Remove an element from the universe and destroy it."""
-        del(self.origin.data[self.key])
-        del self.universe.categories[self.category][self.subkey]
+        for facet in dict(self.facethash):
+            self.remove_facet(facet)
+        del self.universe.groups[self.group][self.subkey]
         del self.universe.contents[self.key]
         del self
 
     def facets(self):
         """Return a list of non-inherited facets for this element."""
-        if self.old_style:
-            try:
-                return self.origin.data[self.key].keys()
-            except (AttributeError, KeyError):
-                return []
-        else:
-            return self.facethash
+        return self.facethash
 
     def has_facet(self, facet):
         """Return whether the non-inherited facet exists."""
@@ -115,9 +85,11 @@ class Element:
 
     def remove_facet(self, facet):
         """Remove a facet from the element."""
-        if self.has_facet(facet):
-            del(self.origin.data[self.key][facet])
-            self.origin.modified = True
+        if ".".join((self.key, facet)) in self.origin.data:
+            del self.origin.data[".".join((self.key, facet))]
+        if facet in self.facethash:
+            del self.facethash[facet]
+        self.origin.modified = True
 
     def ancestry(self):
         """Return a list of the element's inheritance lineage."""
@@ -139,10 +111,7 @@ class Element:
         if default is None:
             default = ""
         try:
-            if self.old_style:
-                return self.origin.data[self.key][facet]
-            else:
-                return self.origin.data[".".join((self.key, facet))]
+            return self.origin.data[".".join((self.key, facet))]
         except (KeyError, TypeError):
             pass
         if self.has_facet("inherit"):
@@ -154,17 +123,31 @@ class Element:
 
     def set(self, facet, value):
         """Set values."""
-        if not self.has_facet(facet) or not self.get(facet) == value:
-            if self.old_style:
-                if self.key not in self.origin.data:
-                    self.origin.data[self.key] = {}
-                self.origin.data[self.key][facet] = value
-            else:
-                node = ".".join((self.key, facet))
-                self.origin.data[node] = value
-                self.facethash[node] = self.origin.data[node]
+        if not self.origin.is_writeable() and not self.universe.loading:
+            # break if there is an attempt to update an element from a
+            # read-only file, unless the universe is in the midst of loading
+            # updated data from files
+            raise PermissionError("Altering elements in read-only files is "
+                                  "disallowed")
+        # Coerce some values to appropriate data types
+        # TODO(fungi) Move these to a separate validation mechanism
+        if facet in ["loglevel"]:
+            value = int(value)
+        elif facet in ["administrator"]:
+            value = bool(value)
+
+        # The canonical node for this facet within its origin
+        node = ".".join((self.key, facet))
+
+        if node not in self.origin.data or self.origin.data[node] != value:
+            # Be careful to only update the origin's contents when required,
+            # since that affects whether the backing file gets written
+            self.origin.data[node] = value
             self.origin.modified = True
 
+        # Make sure this facet is included in the element's facets
+        self.facethash[facet] = self.origin.data[node]
+
     def append(self, facet, value):
         """Append value to a list."""
         newlist = self.get(facet)
@@ -202,8 +185,8 @@ class Element:
     def can_run(self, command):
         """Check if the user can run this command object."""
 
-        # has to be in the commands category
-        if command not in self.universe.categories["command"].values():
+        # has to be in the commands group
+        if command not in self.universe.groups["command"].values():
             result = False
 
         # avatars of administrators can run any command
@@ -297,9 +280,9 @@ class Element:
     def portals(self):
         """Map the portal directions for an area to neighbors."""
         portals = {}
-        if re.match("""^area:-?\d+,-?\d+,-?\d+$""", self.key):
+        if re.match(r"""^area\.-?\d+,-?\d+,-?\d+$""", self.key):
             coordinates = [(int(x))
-                           for x in self.key.split(":")[1].split(",")]
+                           for x in self.key.split(".")[-1].split(",")]
             offsets = dict(
                 (x,
                  self.universe.contents["mudpy.movement.%s" % x].get("vector")
@@ -307,7 +290,7 @@ class Element:
             for portal in self.get("gridlinks"):
                 adjacent = map(lambda c, o: c + o,
                                coordinates, offsets[portal])
-                neighbor = "area:" + ",".join(
+                neighbor = "area." + ",".join(
                     [(str(x)) for x in adjacent]
                 )
                 if neighbor in self.universe.contents:
@@ -341,17 +324,18 @@ class Universe:
 
     def __init__(self, filename="", load=False):
         """Initialize the universe."""
-        self.categories = {}
+        self.groups = {}
         self.contents = {}
-        self.default_origins = {}
         self.directions = set()
+        self.loading = False
         self.loglines = []
-        self.private_files = []
+        self.origins = {}
         self.reload_flag = False
         self.setup_loglines = []
         self.startdir = os.getcwd()
         self.terminate_flag = False
         self.userlist = []
+        self.versions = None
         if not filename:
             possible_filenames = [
                 "etc/mudpy.yaml",
@@ -373,6 +357,9 @@ class Universe:
     def load(self):
         """Load universe data from persistent storage."""
 
+        # while loading, it's safe to update elements from read-only files
+        self.loading = True
+
         # it's possible for this to enter before logging configuration is read
         pending_loglines = []
 
@@ -392,17 +379,26 @@ class Universe:
                         del self.files[data_filename]
 
             # start loading from the initial file
-            mudpy.data.DataFile(self.filename, self)
+            mudpy.data.Data(self.filename, self)
+
+        # load default storage locations for groups
+        if hasattr(self, "contents") and "mudpy.filing" in self.contents:
+            self.origins.update(self.contents["mudpy.filing"].get(
+                "groups", {}))
+
+        # add some builtin groups we know we'll need
+        for group in ("account", "actor", "internal"):
+            self.add_group(group)
 
         # make a list of inactive avatars
         inactive_avatars = []
-        for account in self.categories["account"].values():
+        for account in self.groups.get("account", {}).values():
             for avatar in account.get("avatars"):
                 try:
                     inactive_avatars.append(self.contents[avatar])
                 except KeyError:
                     pending_loglines.append((
-                        "Missing avatar \"%s\", possible data corruption" %
+                        'Missing avatar "%s", possible data corruption' %
                         avatar, 6))
         for user in self.userlist:
             if user.avatar in inactive_avatars:
@@ -423,6 +419,10 @@ class Universe:
         for element in self.contents.values():
             element.update_location()
             element.clean_contents()
+
+        # done loading, so disallow updating elements from read-only files
+        self.loading = False
+
         return pending_loglines
 
     def new(self):
@@ -487,7 +487,20 @@ class Universe:
 
     def get_time(self):
         """Convenience method to get the elapsed time counter."""
-        return self.categories["internal"]["counters"].get("elapsed")
+        return self.groups["internal"]["counters"].get("elapsed")
+
+    def add_group(self, group, fallback=None):
+        """Set up group tracking/metadata."""
+        if group not in self.origins:
+            self.origins[group] = {}
+        if not fallback:
+            fallback = mudpy.data.find_file(
+                    ".".join((group, "yaml")), universe=self)
+        if "fallback" not in self.origins[group]:
+            self.origins[group]["fallback"] = fallback
+        flags = self.origins[group].get("flags", None)
+        if fallback not in self.files:
+            mudpy.data.Data(fallback, self, flags=flags)
 
 
 class User:
@@ -512,7 +525,7 @@ class User:
         self.output_queue = []
         self.partial_input = b""
         self.password_tries = 0
-        self.state = "initial"
+        self.state = "telopt_negotiation"
         self.telopts = {}
 
     def quit(self):
@@ -534,9 +547,8 @@ class User:
     def check_idle(self):
         """Warn or disconnect idle users as appropriate."""
         idletime = universe.get_time() - self.last_input
-        linkdead_dict = universe.categories["internal"]["time"].get(
-            "linkdead"
-        )
+        linkdead_dict = universe.contents[
+            "mudpy.timing.idle.disconnect"].facets()
         if self.state in linkdead_dict:
             linkdead_state = self.state
         else:
@@ -558,7 +570,7 @@ class User:
             log(logline, 2)
             self.state = "disconnecting"
             self.menu_seen = False
-        idle_dict = universe.categories["internal"]["time"].get("idle")
+        idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
         if self.state in idle_dict:
             idle_state = self.state
         else:
@@ -649,11 +661,15 @@ class User:
     def authenticate(self):
         """Flag the user as authenticated and disconnect duplicates."""
         if self.state is not "authenticated":
-            log("User " + self.account.get("name") + " logged in.", 2)
             self.authenticated = True
             if ("mudpy.limit" in universe.contents and self.account.subkey in
                     universe.contents["mudpy.limit"].get("admins")):
-                self.account.set("administrator", "True")
+                self.account.set("administrator", True)
+                log("Administrator %s authenticated." %
+                    self.account.get("name"), 2)
+            else:
+                # log("User %s authenticated." % self.account.get("name"), 2)
+                log("User %s authenticated." % self.account.subkey, 2)
 
     def show_menu(self):
         """Send the user their current menu."""
@@ -790,7 +806,7 @@ class User:
             self.check_idle()
 
         # if output is paused, decrement the counter
-        if self.state == "initial":
+        if self.state == "telopt_negotiation":
             if self.negotiation_pause:
                 self.negotiation_pause -= 1
             else:
@@ -819,13 +835,13 @@ class User:
         if self.output_queue:
             try:
                 self.connection.send(self.output_queue[0])
-            except BrokenPipeError:
+            except (BrokenPipeError, ConnectionResetError):
                 if self.account and self.account.get("name"):
                     account = self.account.get("name")
                 else:
                     account = "an unknown user"
                 self.state = "disconnecting"
-                log("Broken pipe sending to %s." % account, 7)
+                log("Disconnected while sending to %s." % account, 7)
             del self.output_queue[0]
 
     def enqueue_input(self):
@@ -867,8 +883,8 @@ class User:
                 line = line.strip()
 
                 # log non-printable characters remaining
-                if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
-                                           mudpy.telnet.HIM):
+                if not mudpy.telnet.is_enabled(
+                        self, mudpy.telnet.TELOPT_BINARY, mudpy.telnet.HIM):
                     asciiline = bytes([x for x in line if 32 <= x <= 126])
                     if line != asciiline:
                         logline = "Non-ASCII characters from "
@@ -883,7 +899,7 @@ class User:
                 try:
                     line = line.decode("utf-8")
                 except UnicodeDecodeError:
-                    logline = "Non-UTF-8 characters from "
+                    logline = "Non-UTF-8 sequence from "
                     if self.account and self.account.get("name"):
                         logline += self.account.get("name") + ": "
                     else:
@@ -900,17 +916,13 @@ class User:
     def new_avatar(self):
         """Instantiate a new, unconfigured avatar for this user."""
         counter = 0
-        while "avatar:" + self.account.get("name") + ":" + str(
-            counter
-        ) in universe.categories["actor"].keys():
+        while ("avatar_%s_%s" % (self.account.get("name"), counter)
+                in universe.groups.get("actor", {}).keys()):
             counter += 1
         self.avatar = Element(
-            "actor:avatar:" + self.account.get("name") + ":" + str(
-                counter
-            ),
-            universe, old_style=True
-        )
-        self.avatar.append("inherit", "archetype:avatar")
+            "actor.avatar_%s_%s" % (self.account.get("name"), counter),
+            universe)
+        self.avatar.append("inherit", "archetype.avatar")
         self.account.append("avatars", self.avatar.key)
 
     def delete_avatar(self, avatar):
@@ -959,7 +971,7 @@ class User:
             try:
                 avatars.append(universe.contents[avatar].get("name"))
             except KeyError:
-                log("Missing avatar \"%s\", possible data corruption." %
+                log('Missing avatar "%s", possible data corruption.' %
                     avatar, 6)
         return avatars
 
@@ -1104,8 +1116,10 @@ def wrap_ansi_text(text, width):
     # ignoring color escape sequences
     rel_pos = 0
 
-    # the absolute position of the most recent whitespace character
-    last_whitespace = 0
+    # the absolute and relative positions of the most recent whitespace
+    # character
+    last_abs_whitespace = 0
+    last_rel_whitespace = 0
 
     # whether the current character is part of a color escape sequence
     escape = False
@@ -1119,39 +1133,37 @@ def wrap_ansi_text(text, width):
         # the current character is the escape character
         if each_character == "\x1b" and not escape:
             escape = True
+            rel_pos -= 1
 
         # the current character is within an escape sequence
         elif escape:
-
-            # the current character is m, which terminates the
-            # escape sequence
+            rel_pos -= 1
             if each_character == "m":
+                # the current character is m, which terminates the
+                # escape sequence
                 escape = False
 
+        # the current character is a space
+        elif each_character == " ":
+            last_abs_whitespace = abs_pos
+            last_rel_whitespace = rel_pos
+
         # the current character is a newline, so reset the relative
-        # position (start a new line)
+        # position too (start a new line)
         elif each_character == "\n":
             rel_pos = 0
-            last_whitespace = abs_pos
-
-        # the current character meets the requested maximum line width,
-        # so we need to backtrack and find a space at which to wrap;
-        # special care is taken to avoid an off-by-one in case the
-        # current character is a double-width glyph
-        elif each_character != "\r" and (
-            rel_pos >= width or (
-                rel_pos >= width - 1 and glyph_columns(
-                    each_character
-                ) == 2
-            )
-        ):
+            last_abs_whitespace = abs_pos
+            last_rel_whitespace = rel_pos
 
-            # it's always possible we landed on whitespace
-            if unicodedata.category(each_character) in ("Cc", "Zs"):
-                last_whitespace = abs_pos
+        # the current character meets the requested maximum line width, so we
+        # need to wrap unless the current word is wider than the terminal (in
+        # which case we let it do the wrapping instead)
+        if last_rel_whitespace != 0 and (rel_pos > width or (
+                rel_pos > width - 1 and glyph_columns(each_character) == 2)):
 
-            # insert an eol in place of the space
-            text = text[:last_whitespace] + "\r\n" + text[last_whitespace + 1:]
+            # insert an eol in place of the last space
+            text = (text[:last_abs_whitespace] + "\r\n" +
+                    text[last_abs_whitespace + 1:])
 
             # increase the absolute position because an eol is two
             # characters but the space it replaced was only one
@@ -1159,17 +1171,17 @@ def wrap_ansi_text(text, width):
 
             # now we're at the begining of a new line, plus the
             # number of characters wrapped from the previous line
-            rel_pos = 0
-            for remaining_characters in text[last_whitespace:abs_pos]:
-                rel_pos += glyph_columns(remaining_characters)
+            rel_pos -= last_rel_whitespace
+            last_rel_whitespace = 0
 
         # as long as the character is not a carriage return and the
         # other above conditions haven't been met, count it as a
         # printable character
         elif each_character != "\r":
             rel_pos += glyph_columns(each_character)
-            if unicodedata.category(each_character) in ("Cc", "Zs"):
-                last_whitespace = abs_pos
+            if each_character in (" ", "\n"):
+                last_abs_whitespace = abs_pos
+                last_rel_whitespace = rel_pos
 
         # increase the absolute position for every character
         abs_pos += 1
@@ -1306,7 +1318,7 @@ def replace_macros(user, text, is_input=False):
                 replacement = replacement[:-2]
             else:
                 replacement = ""
-                log("Couldn't read included " + incfile + " file.", 6)
+                log("Couldn't read included " + incfile + " file.", 7)
 
         # if we get here, log and replace it with null
         else:
@@ -1360,48 +1372,41 @@ def on_pulse():
         user.pulse()
 
     # add an element for counters if it doesn't exist
-    if "counters" not in universe.categories["internal"]:
-        universe.categories["internal"]["counters"] = Element(
-            "internal:counters", universe, old_style=True
-        )
+    if "counters" not in universe.groups.get("internal", {}):
+        Element("internal.counters", universe)
 
     # update the log every now and then
-    if not universe.categories["internal"]["counters"].get("mark"):
+    if not universe.groups["internal"]["counters"].get("mark"):
         log(str(len(universe.userlist)) + " connection(s)")
-        universe.categories["internal"]["counters"].set(
-            "mark", universe.categories["internal"]["time"].get(
-                "frequency_log"
-            )
+        universe.groups["internal"]["counters"].set(
+            "mark", universe.contents["mudpy.timing"].get("status")
         )
     else:
-        universe.categories["internal"]["counters"].set(
-            "mark", universe.categories["internal"]["counters"].get(
+        universe.groups["internal"]["counters"].set(
+            "mark", universe.groups["internal"]["counters"].get(
                 "mark"
             ) - 1
         )
 
     # periodically save everything
-    if not universe.categories["internal"]["counters"].get("save"):
+    if not universe.groups["internal"]["counters"].get("save"):
         universe.save()
-        universe.categories["internal"]["counters"].set(
-            "save", universe.categories["internal"]["time"].get(
-                "frequency_save"
-            )
+        universe.groups["internal"]["counters"].set(
+            "save", universe.contents["mudpy.timing"].get("save")
         )
     else:
-        universe.categories["internal"]["counters"].set(
-            "save", universe.categories["internal"]["counters"].get(
+        universe.groups["internal"]["counters"].set(
+            "save", universe.groups["internal"]["counters"].get(
                 "save"
             ) - 1
         )
 
     # pause for a configurable amount of time (decimal seconds)
-    time.sleep(universe.categories["internal"]
-               ["time"].get("increment"))
+    time.sleep(universe.contents["mudpy.timing"].get("increment"))
 
     # increase the elapsed increment counter
-    universe.categories["internal"]["counters"].set(
-        "elapsed", universe.categories["internal"]["counters"].get(
+    universe.groups["internal"]["counters"].set(
+        "elapsed", universe.groups["internal"]["counters"].get(
             "elapsed", 0
         ) + 1
     )
@@ -1477,7 +1482,7 @@ def get_menu(state, error=None, choices=None):
 
 def menu_echo_on(state):
     """True if echo is on, false if it is off."""
-    return universe.categories["menu"][state].get("echo", True)
+    return universe.groups["menu"][state].get("echo", True)
 
 
 def get_echo_message(state):
@@ -1490,7 +1495,7 @@ def get_echo_message(state):
 
 def get_default_menu_choice(state):
     """Return the default choice for a menu."""
-    return universe.categories["menu"][state].get("default")
+    return universe.groups["menu"][state].get("default")
 
 
 def get_formatted_default_menu_choice(state):
@@ -1510,7 +1515,7 @@ def get_menu_description(state, error):
 
         # try to get an error message matching the condition
         # and current state
-        description = universe.categories[
+        description = universe.groups[
             "menu"][state].get("error_" + error)
         if not description:
             description = "That is not a valid choice..."
@@ -1520,7 +1525,7 @@ def get_menu_description(state, error):
     else:
 
         # try to get a menu description for the current state
-        description = universe.categories["menu"][state].get("description")
+        description = universe.groups["menu"][state].get("description")
 
     # return the description or error message
     if description:
@@ -1530,7 +1535,7 @@ def get_menu_description(state, error):
 
 def get_menu_prompt(state):
     """Try to get a prompt, if it was defined."""
-    prompt = universe.categories["menu"][state].get("prompt")
+    prompt = universe.groups["menu"][state].get("prompt")
     if prompt:
         prompt += " "
     return prompt
@@ -1538,7 +1543,7 @@ def get_menu_prompt(state):
 
 def get_menu_choices(user):
     """Return a dict of choice:meaning."""
-    menu = universe.categories["menu"][user.state]
+    menu = universe.groups["menu"][user.state]
     create_choices = menu.get("create")
     if create_choices:
         choices = eval(create_choices)
@@ -1549,7 +1554,7 @@ def get_menu_choices(user):
     creates = {}
     for facet in menu.facets():
         if facet.startswith("demand_") and not eval(
-           universe.categories["menu"][user.state].get(facet)
+           universe.groups["menu"][user.state].get(facet)
            ):
             ignores.append(facet.split("_", 2)[1])
         elif facet.startswith("create_"):
@@ -1582,17 +1587,17 @@ def get_formatted_menu_choices(state, choices):
 def get_menu_branches(state):
     """Return a dict of choice:branch."""
     branches = {}
-    for facet in universe.categories["menu"][state].facets():
+    for facet in universe.groups["menu"][state].facets():
         if facet.startswith("branch_"):
             branches[
                 facet.split("_", 2)[1]
-            ] = universe.categories["menu"][state].get(facet)
+            ] = universe.groups["menu"][state].get(facet)
     return branches
 
 
 def get_default_branch(state):
     """Return the default branch."""
-    return universe.categories["menu"][state].get("branch")
+    return universe.groups["menu"][state].get("branch")
 
 
 def get_choice_branch(user, choice):
@@ -1609,17 +1614,17 @@ def get_choice_branch(user, choice):
 def get_menu_actions(state):
     """Return a dict of choice:branch."""
     actions = {}
-    for facet in universe.categories["menu"][state].facets():
+    for facet in universe.groups["menu"][state].facets():
         if facet.startswith("action_"):
             actions[
                 facet.split("_", 2)[1]
-            ] = universe.categories["menu"][state].get(facet)
+            ] = universe.groups["menu"][state].get(facet)
     return actions
 
 
 def get_default_action(state):
     """Return the default action."""
-    return universe.categories["menu"][state].get("action")
+    return universe.groups["menu"][state].get("action")
 
 
 def get_choice_action(user, choice):
@@ -1694,13 +1699,13 @@ def handler_entering_account_name(user):
             user.error = "bad_name"
 
         # if that account exists, time to request a password
-        elif name in universe.categories["account"]:
-            user.account = universe.categories["account"][name]
+        elif name in universe.groups.get("account", {}):
+            user.account = universe.groups["account"][name]
             user.state = "checking_password"
 
         # otherwise, this could be a brand new user
         else:
-            user.account = Element("account:" + name, universe, old_style=True)
+            user.account = Element("account.%s" % name, universe)
             user.account.set("name", name)
             log("New user: " + name, 2)
             user.state = "checking_new_account_name"
@@ -1843,8 +1848,8 @@ def handler_active(user):
         command_name = command_name.lower()
 
         # the command matches a command word for which we have data
-        if command_name in universe.categories["command"]:
-            command = universe.categories["command"][command_name]
+        if command_name in universe.groups["command"]:
+            command = universe.groups["command"][command_name]
         else:
             command = None
 
@@ -1890,7 +1895,7 @@ def command_reload(actor):
         log(
             "User " +
             actor.owner.account.get("name") + " reloaded the world.",
-            8
+            6
         )
 
         # set a flag to reload
@@ -1911,8 +1916,8 @@ def command_help(actor, parameters):
     if parameters and actor.owner:
 
         # is the command word one for which we have data?
-        if parameters in universe.categories["command"]:
-            command = universe.categories["command"][parameters]
+        if parameters in universe.groups["command"]:
+            command = universe.groups["command"][parameters]
         else:
             command = None
 
@@ -1940,8 +1945,8 @@ def command_help(actor, parameters):
             if see_also:
                 really_see_also = ""
                 for item in see_also:
-                    if item in universe.categories["command"]:
-                        command = universe.categories["command"][item]
+                    if item in universe.groups["command"]:
+                        command = universe.groups["command"][item]
                         if actor.can_run(command):
                             if really_see_also:
                                 really_see_also += ", "
@@ -1962,10 +1967,10 @@ def command_help(actor, parameters):
 
         # give a sorted list of commands with descriptions if provided
         output = "These are the commands available to you:$(eol)$(eol)"
-        sorted_commands = list(universe.categories["command"].keys())
+        sorted_commands = list(universe.groups["command"].keys())
         sorted_commands.sort()
         for item in sorted_commands:
-            command = universe.categories["command"][item]
+            command = universe.groups["command"][item]
             if actor.can_run(command):
                 description = command.get("description")
                 if not description:
@@ -1975,8 +1980,8 @@ def command_help(actor, parameters):
                 else:
                     output += "   $(grn)"
                 output += item + "$(nrm) - " + description + "$(eol)"
-        output += ("$(eol)Enter \"help COMMAND\" for help on a command "
-                   "named \"COMMAND\".")
+        output += ('$(eol)Enter "help COMMAND" for help on a command '
+                   'named "COMMAND".')
 
     # send the accumulated output to the user
     actor.send(output)
@@ -2006,13 +2011,13 @@ def command_say(actor, parameters):
 
     # if the message is wrapped in quotes, remove them and leave contents
     # intact
-    if parameters.startswith("\"") and parameters.endswith("\""):
+    if parameters.startswith('"') and parameters.endswith('"'):
         message = parameters[1:-1]
         literal = True
 
     # otherwise, get rid of stray quote marks on the ends of the message
     else:
-        message = parameters.strip("\"'`")
+        message = parameters.strip('''"'`''')
         literal = False
 
     # the user entered a message
@@ -2070,9 +2075,9 @@ def command_say(actor, parameters):
     # tell the area
     if message:
         actor.echo_to_location(
-            actor.get("name") + " " + action + "s, \"" + message + "\""
+            actor.get("name") + " " + action + 's, "' + message + '"'
         )
-        actor.send("You " + action + ", \"" + message + "\"")
+        actor.send("You " + action + ', "' + message + '"')
 
     # there was no message
     else:
@@ -2098,73 +2103,72 @@ def command_show(actor, parameters):
     arguments = parameters.split()
     if not parameters:
         message = "What do you want to show?"
+    elif arguments[0] == "version":
+        message = repr(universe.versions)
     elif arguments[0] == "time":
-        message = universe.categories["internal"]["counters"].get(
+        message = universe.groups["internal"]["counters"].get(
             "elapsed"
         ) + " increments elapsed since the world was created."
-    elif arguments[0] == "categories":
-        message = "These are the element categories:$(eol)"
-        categories = list(universe.categories.keys())
-        categories.sort()
-        for category in categories:
-            message += "$(eol)   $(grn)" + category + "$(nrm)"
+    elif arguments[0] == "groups":
+        message = "These are the element groups:$(eol)"
+        groups = list(universe.groups.keys())
+        groups.sort()
+        for group in groups:
+            message += "$(eol)   $(grn)" + group + "$(nrm)"
     elif arguments[0] == "files":
         message = "These are the current files containing the universe:$(eol)"
-        filenames = list(universe.files.keys())
-        filenames.sort()
+        filenames = sorted(universe.files)
         for filename in filenames:
             if universe.files[filename].is_writeable():
                 status = "rw"
             else:
                 status = "ro"
-            message += ("$(eol)   $(red)(" + status + ") $(grn)" + filename
-                        + "$(nrm)")
-    elif arguments[0] == "category":
+            message += ("$(eol)   $(red)(%s) $(grn)%s$(nrm)" %
+                        (status, filename))
+            if universe.files[filename].flags:
+                message += (" $(yel)[%s]$(nrm)" %
+                            ",".join(universe.files[filename].flags))
+    elif arguments[0] == "group":
         if len(arguments) != 2:
-            message = "You must specify one category."
-        elif arguments[1] in universe.categories:
-            message = ("These are the elements in the \"" + arguments[1]
-                       + "\" category:$(eol)")
+            message = "You must specify one group."
+        elif arguments[1] in universe.groups:
+            message = ('These are the elements in the "' + arguments[1]
+                       + '" group:$(eol)')
             elements = [
                 (
-                    universe.categories[arguments[1]][x].key
-                ) for x in universe.categories[arguments[1]].keys()
+                    universe.groups[arguments[1]][x].key
+                ) for x in universe.groups[arguments[1]].keys()
             ]
             elements.sort()
             for element in elements:
                 message += "$(eol)   $(grn)" + element + "$(nrm)"
         else:
-            message = "Category \"" + arguments[1] + "\" does not exist."
+            message = 'Group "' + arguments[1] + '" does not exist.'
     elif arguments[0] == "file":
         if len(arguments) != 2:
             message = "You must specify one file."
         elif arguments[1] in universe.files:
-            message = ("These are the elements in the \"" + arguments[1]
-                       + "\" file:$(eol)")
-            elements = universe.files[arguments[1]].data.keys()
-            elements.sort()
+            message = ('These are the nodes in the "' + arguments[1]
+                       + '" file:$(eol)')
+            elements = sorted(universe.files[arguments[1]].data)
             for element in elements:
                 message += "$(eol)   $(grn)" + element + "$(nrm)"
         else:
-            message = "Category \"" + arguments[1] + "\" does not exist."
+            message = 'File "%s" does not exist.' % arguments[1]
     elif arguments[0] == "element":
         if len(arguments) != 2:
             message = "You must specify one element."
         elif arguments[1].strip(".") in universe.contents:
             element = universe.contents[arguments[1].strip(".")]
-            message = ("These are the properties of the \"" + arguments[1]
-                       + "\" element (in \"" + element.origin.filename
-                       + "\"):$(eol)")
+            message = ('These are the properties of the "' + arguments[1]
+                       + '" element (in "' + element.origin.source
+                       + '"):$(eol)')
             facets = element.facets()
             for facet in sorted(facets):
-                if element.old_style:
-                    message += ("$(eol)   $(grn)%s: $(red)%s$(nrm)" %
-                                (facet, escape_macros(element.get(facet))))
-                else:
-                    message += ("$(eol)   $(grn)%s: $(red)%s$(nrm)" %
-                                (facet, str(facets[facet])))
+                message += ("$(eol)   $(grn)%s: $(red)%s$(nrm)" %
+                            (facet, str(facets[facet])))
         else:
-            message = "Element \"" + arguments[1] + "\" does not exist."
+            message = 'Element "' + arguments[1] + '" does not exist.'
     elif arguments[0] == "result":
         if len(arguments) < 2:
             message = "You need to specify an expression."
@@ -2176,21 +2180,21 @@ def command_show(actor, parameters):
                            "$(eol)$(bld)%s$(nrm)" % e)
     elif arguments[0] == "log":
         if len(arguments) == 4:
-            if re.match("^\d+$", arguments[3]) and int(arguments[3]) >= 0:
+            if re.match(r"^\d+$", arguments[3]) and int(arguments[3]) >= 0:
                 stop = int(arguments[3])
             else:
                 stop = -1
         else:
             stop = 0
         if len(arguments) >= 3:
-            if re.match("^\d+$", arguments[2]) and int(arguments[2]) > 0:
+            if re.match(r"^\d+$", arguments[2]) and int(arguments[2]) > 0:
                 start = int(arguments[2])
             else:
                 start = -1
         else:
             start = 10
         if len(arguments) >= 2:
-            if (re.match("^\d+$", arguments[1])
+            if (re.match(r"^\d+$", arguments[1])
                     and 0 <= int(arguments[1]) <= 9):
                 level = int(arguments[1])
             else:
@@ -2205,7 +2209,7 @@ def command_show(actor, parameters):
             message = ("When specified, level must be 0-9 (default 1), "
                        "start and stop must be >=1 (default 10 and 1).")
     else:
-        message = "I don't know what \"" + parameters + "\" is."
+        message = '''I don't know what "''' + parameters + '" is.'
     actor.send(message)
 
 
@@ -2222,10 +2226,10 @@ def command_create(actor, parameters):
         if len(arguments) == 2:
             element, filename = arguments
             if element in universe.contents:
-                message = "The \"" + element + "\" element already exists."
+                message = 'The "' + element + '" element already exists.'
             else:
-                message = ("You create \"" +
-                           element + "\" within the universe.")
+                message = ('You create "' +
+                           element + '" within the universe.')
                 logline = actor.owner.account.get(
                     "name"
                 ) + " created an element: " + element
@@ -2233,10 +2237,10 @@ def command_create(actor, parameters):
                     logline += " in file " + filename
                     if filename not in universe.files:
                         message += (
-                            " Warning: \"" + filename + "\" is not yet "
+                            ' Warning: "' + filename + '" is not yet '
                             "included in any other file and will not be read "
                             "on startup unless this is remedied.")
-                Element(element, universe, filename, old_style=True)
+                Element(element, universe, filename)
                 log(logline, 6)
         elif len(arguments) > 2:
             message = "You can only specify an element and a filename."
@@ -2250,11 +2254,11 @@ def command_destroy(actor, parameters):
             message = "You must specify an element to destroy."
         else:
             if parameters not in universe.contents:
-                message = "The \"" + parameters + "\" element does not exist."
+                message = 'The "' + parameters + '" element does not exist.'
             else:
                 universe.contents[parameters].destroy()
-                message = ("You destroy \"" + parameters
-                           + "\" within the universe.")
+                message = ('You destroy "' + parameters
+                           + '" within the universe.')
                 log(
                     actor.owner.account.get(
                         "name"
@@ -2271,22 +2275,33 @@ def command_set(actor, parameters):
     else:
         arguments = parameters.split(" ", 2)
         if len(arguments) == 1:
-            message = ("What facet of element \"" + arguments[0]
-                       + "\" would you like to set?")
+            message = ('What facet of element "' + arguments[0]
+                       + '" would you like to set?')
         elif len(arguments) == 2:
-            message = ("What value would you like to set for the \"" +
-                       arguments[1] + "\" facet of the \"" + arguments[0]
-                       + "\" element?")
+            message = ('What value would you like to set for the "' +
+                       arguments[1] + '" facet of the "' + arguments[0]
+                       + '" element?')
         else:
             element, facet, value = arguments
             if element not in universe.contents:
-                message = "The \"" + element + "\" element does not exist."
+                message = 'The "' + element + '" element does not exist.'
             else:
-                universe.contents[element].set(facet, value)
-                message = ("You have successfully (re)set the \"" + facet
-                           + "\" facet of element \"" + element
-                           + "\". Try \"show element " +
-                           element + "\" for verification.")
+                try:
+                    universe.contents[element].set(facet, value)
+                except PermissionError:
+                    message = ('The "%s" element is kept in read-only file '
+                               '"%s" and cannot be altered.' %
+                               (element, universe.contents[
+                                        element].origin.source))
+                except ValueError:
+                    message = ('Value "%s" of type "%s" cannot be coerced '
+                               'to the correct datatype for facet "%s".' %
+                               (value, type(value), facet))
+                else:
+                    message = ('You have successfully (re)set the "' + facet
+                               + '" facet of element "' + element
+                               + '". Try "show element ' +
+                               element + '" for verification.')
     actor.send(message)
 
 
@@ -2297,23 +2312,23 @@ def command_delete(actor, parameters):
     else:
         arguments = parameters.split(" ")
         if len(arguments) == 1:
-            message = ("What facet of element \"" + arguments[0]
-                       + "\" would you like to delete?")
+            message = ('What facet of element "' + arguments[0]
+                       + '" would you like to delete?')
         elif len(arguments) != 2:
             message = "You may only specify an element and a facet."
         else:
             element, facet = arguments
             if element not in universe.contents:
-                message = "The \"" + element + "\" element does not exist."
+                message = 'The "' + element + '" element does not exist.'
             elif facet not in universe.contents[element].facets():
-                message = ("The \"" + element + "\" element has no \"" + facet
-                           + "\" facet.")
+                message = ('The "' + element + '" element has no "' + facet
+                           + '" facet.')
             else:
                 universe.contents[element].remove_facet(facet)
-                message = ("You have successfully deleted the \"" + facet
-                           + "\" facet of element \"" + element
-                           + "\". Try \"show element " +
-                           element + "\" for verification.")
+                message = ('You have successfully deleted the "' + facet
+                           + '" facet of element "' + element
+                           + '". Try "show element ' +
+                           element + '" for verification.')
     actor.send(message)
 
 
@@ -2322,7 +2337,7 @@ def command_error(actor, input_data):
 
     # 90% of the time use a generic error
     if random.randrange(10):
-        message = "I'm not sure what \"" + input_data + "\" means..."
+        message = '''I'm not sure what "''' + input_data + '''" means...'''
 
     # 10% of the time use the classic diku error
     else:
@@ -2469,9 +2484,6 @@ def setup():
         log(*logline)
     universe.setup_loglines = []
 
-    # log an initial message
-    log("Started mudpy with command line: " + " ".join(sys.argv))
-
     # fork and disassociate
     daemonize(universe)
 
@@ -2484,6 +2496,17 @@ def setup():
     # make the pidfile
     create_pidfile(universe)
 
+    # load and store diagnostic info
+    universe.versions = mudpy.version.Versions("mudpy")
+
+    # log startup diagnostic messages
+    log("On %s at %s" % (universe.versions.python_version, sys.executable), 1)
+    log("Import path: %s" % ", ".join(sys.path), 1)
+    log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
+    log("Other python packages: %s" % universe.versions.environment_text, 1)
+    log("Started %s with command line: %s" % (
+        universe.versions.version, " ".join(sys.argv)), 1)
+
     # pass the initialized universe back
     return universe