Imported from archive.
[mudpy.git] / mudpy.py
index 8b91054..06563f7 100644 (file)
--- a/mudpy.py
+++ b/mudpy.py
@@ -1,68 +1,26 @@
 """Core objects for the mudpy engine."""
 
-# Copyright (c) 2006 mudpy, Jeremy Stanley <fungi@yuggoth.org>, all rights reserved.
-# Licensed per terms in the LICENSE file distributed with this software.
+# Copyright (c) 2005, 2006 Jeremy Stanley <fungi@yuggoth.org>. All rights
+# reserved. Licensed per terms in the LICENSE file distributed with this
+# software.
 
 # import some things we need
 from ConfigParser import RawConfigParser
 from md5 import new as new_md5
-from os import _exit, R_OK, W_OK, access, chmod, close, fork, getpid, makedirs, remove, setsid, stat
-from os.path import abspath, dirname, exists, isabs, join as path_join
+from os import _exit, R_OK, W_OK, access, chdir, chmod, close, fork, getcwd, getpid, listdir, makedirs, remove, rename, setsid, stat, umask
+from os.path import abspath, basename, dirname, exists, isabs, join as path_join
 from random import choice, randrange
 from re import match
 from signal import SIGHUP, SIGTERM, signal
 from socket import AF_INET, SO_REUSEADDR, SOCK_STREAM, SOL_SOCKET, socket
 from stat import S_IMODE, ST_MODE
-from sys import stderr
+from string import digits, letters, punctuation, uppercase
+from sys import argv, stderr
 from syslog import LOG_PID, LOG_INFO, LOG_DAEMON, closelog, openlog, syslog
 from telnetlib import DO, DONT, ECHO, EOR, GA, IAC, LINEMODE, SB, SE, SGA, WILL, WONT
 from time import asctime, sleep
 from traceback import format_exception
 
-def excepthook(excepttype, value, traceback):
-       """Handle uncaught exceptions."""
-
-       # assemble the list of errors into a single string
-       message = "".join(format_exception(excepttype, value, traceback))
-
-       # try to log it, if possible
-       try: log(message, 9)
-       except: pass
-
-       # try to write it to stderr, if possible
-       try: stderr.write(message)
-       except: pass
-
-# redefine sys.excepthook with ours
-import sys
-sys.excepthook = excepthook
-
-def sighook(what, where):
-       """Handle external signals."""
-
-       # a generic message
-       message = "Caught signal: "
-
-       # for a hangup signal
-       if what == SIGHUP:
-               message += "hangup (reloading)"
-               universe.reload_modules = True
-
-       # for a terminate signal
-       elif what == SIGTERM:
-               message += "terminate (halting)"
-               universe.terminate_world = True
-
-       # catchall for unexpected signals
-       else: message += str(what) + " (unhandled)"
-
-       # log what happened
-       log(message, 8)
-
-# assign the sgnal handlers
-signal(SIGHUP, sighook)
-signal(SIGTERM, sighook)
-
 class Element:
        """An element of the universe."""
        def __init__(self, key, universe, filename=None):
@@ -227,9 +185,9 @@ class Element:
                # events are elements themselves
                event = Element("event:" + self.key + ":" + counter)
 
-       def send(self, message, eol="$(eol)"):
+       def send(self, message, eol="$(eol)", raw=False, flush=False, add_prompt=True, just_prompt=False):
                """Convenience method to pass messages to an owner."""
-               if self.owner: self.owner.send(message, eol)
+               if self.owner: self.owner.send(message, eol, raw, flush, add_prompt, just_prompt)
 
        def can_run(self, command):
                """Check if the user can run this command object."""
@@ -274,7 +232,7 @@ class Element:
        def move_direction(self, direction):
                """Relocate the element in a specified direction."""
                self.echo_to_location(self.get("name") + " exits " + self.universe.categories["internal"]["directions"].getdict(direction)["exit"] + ".")
-               self.send("You exit " + self.universe.categories["internal"]["directions"].getdict(direction)["exit"] + ".")
+               self.send("You exit " + self.universe.categories["internal"]["directions"].getdict(direction)["exit"] + ".", add_prompt=False)
                self.go_to(self.universe.contents[self.get("location")].link_neighbor(direction))
                self.echo_to_location(self.get("name") + " arrives from " + self.universe.categories["internal"]["directions"].getdict(direction)["enter"] + ".")
        def look_at(self, key):
@@ -342,7 +300,9 @@ class DataFile:
                if self.data.has_option("__control__", "default_files"):
                        origins = makedict(self.data.get("__control__", "default_files"))
                        for key in origins.keys():
-                               if not key in includes: includes.append(key)
+                               if not isabs(origins[key]):
+                                       origins[key] = path_join(dirname(self.filename), origins[key])
+                               if not origins[key] in includes: includes.append(origins[key])
                                self.universe.default_origins[key] = origins[key]
                                if not key in self.universe.categories:
                                        self.universe.categories[key] = {}
@@ -371,6 +331,25 @@ class DataFile:
                        if not exists(dirname(self.filename)):
                                makedirs(dirname(self.filename))
 
+                       # backup the file
+                       if self.data.has_option("__control__", "backup_count"):
+                               max_count = self.data.has_option("__control__", "backup_count")
+                       else: max_count = universe.categories["internal"]["limits"].getint("default_backup_count")
+                       if exists(self.filename) and max_count:
+                               backups = []
+                               for candidate in listdir(dirname(self.filename)):
+                                       if match(basename(self.filename) + """\.\d+$""", candidate):
+                                               backups.append(int(candidate.split(".")[-1]))
+                               backups.sort()
+                               backups.reverse()
+                               for old_backup in backups:
+                                       if old_backup >= max_count-1:
+                                               remove(self.filename+"."+str(old_backup))
+                                       elif not exists(self.filename+"."+str(old_backup+1)):
+                                               rename(self.filename+"."+str(old_backup), self.filename+"."+str(old_backup+1))
+                               if not exists(self.filename+".0"):
+                                       rename(self.filename, self.filename+".0")
+
                        # our data file
                        file_descriptor = file(self.filename, "w")
 
@@ -402,18 +381,19 @@ class DataFile:
 class Universe:
        """The universe."""
 
-       def __init__(self, filename=""):
+       def __init__(self, filename="", load=False):
                """Initialize the universe."""
                self.categories = {}
                self.contents = {}
                self.default_origins = {}
-               self.private_files = []
                self.loglines = []
                self.pending_events_long = {}
                self.pending_events_short = {}
+               self.private_files = []
+               self.reload_flag = False
+               self.startdir = getcwd()
+               self.terminate_flag = False
                self.userlist = []
-               self.terminate_world = False
-               self.reload_modules = False
                if not filename:
                        possible_filenames = [
                                ".mudpyrc",
@@ -429,9 +409,9 @@ class Universe:
                        for filename in possible_filenames:
                                if access(filename, R_OK): break
                if not isabs(filename):
-                       filename = abspath(filename)
+                       filename = path_join(self.startdir, filename)
                self.filename = filename
-               self.load()
+               if load: self.load()
 
        def load(self):
                """Load universe data from persistent storage."""
@@ -470,6 +450,15 @@ class Universe:
                        element.update_location()
                        element.clean_contents()
 
+       def new(self):
+               """Create a new, empty Universe (the Big Bang)."""
+               new_universe = Universe()
+               for attribute in vars(self).keys():
+                       exec("new_universe." + attribute + " = self." + attribute)
+               new_universe.reload_flag = False
+               del self
+               return new_universe
+
        def save(self):
                """Save the universe to persistent storage."""
                for key in self.files: self.files[key].save()
@@ -573,7 +562,7 @@ class User:
                for old_user in universe.userlist:
 
                        # the name is the same but it's not us
-                       if old_user.account.get("name") == self.account.get("name") and old_user is not self:
+                       if hasattr(old_user, "account") and old_user.account.get("name") == self.account.get("name") and old_user is not self:
 
                                # make a note of it
                                log("User " + self.account.get("name") + " reconnected--closing old connection to " + old_user.address + ".", 2)
@@ -636,6 +625,10 @@ class User:
                        # strip extra $(eol) off if present
                        while output.startswith("$(eol)"): output = output[6:]
                        while output.endswith("$(eol)"): output = output[:-6]
+                       extra_lines = output.find("$(eol)$(eol)$(eol)")
+                       while extra_lines > -1:
+                               output = output[:extra_lines] + output[extra_lines+6:]
+                               extra_lines = output.find("$(eol)$(eol)$(eol)")
 
                        # we'll take out GA or EOR and add them back on the end
                        if output.endswith(IAC+GA) or output.endswith(IAC+EOR):
@@ -646,19 +639,26 @@ class User:
                        # start with a newline, append the message, then end
                        # with the optional eol string passed to this function
                        # and the ansi escape to return to normal text
-                       if not just_prompt: output = "$(eol)$(eol)" + output
+                       if not just_prompt:
+                               if not self.output_queue or not self.output_queue[-1].endswith("\r\n"):
+                                       output = "$(eol)$(eol)" + output
+                               elif not self.output_queue[-1].endswith("\r\n"+chr(27)+"[0m"+"\r\n") and not self.output_queue[-1].endswith("\r\n\r\n"):
+                                       output = "$(eol)" + output
                        output += eol + chr(27) + "[0m"
 
                        # tack on a prompt if active
                        if self.state == "active":
                                if not just_prompt: output += "$(eol)"
-                               if add_prompt: output += "> "
+                               if add_prompt:
+                                       output += "> "
+                                       mode = self.avatar.get("mode")
+                                       if mode: output += "(" + mode + ") "
 
                        # find and replace macros in the output
                        output = replace_macros(self, output)
 
-                       # wrap the text at 80 characters
-                       output = wrap_ansi_text(output, 80)
+                       # wrap the text at 79 characters
+                       output = wrap_ansi_text(output, 79)
 
                        # tack the terminator back on
                        if terminate: output += self.terminator
@@ -673,7 +673,7 @@ class User:
                """All the things to do to the user per increment."""
 
                # if the world is terminating, disconnect
-               if universe.terminate_world:
+               if universe.terminate_flag:
                        self.state = "disconnecting"
                        self.menu_seen = False
 
@@ -695,7 +695,8 @@ class User:
                self.enqueue_input()
 
                # there is input waiting in the queue
-               if self.input_queue: handle_user_input(self)
+               if self.input_queue:
+                       handle_user_input(self)
 
        def flush(self):
                """Try to send the last item in the queue and remove it."""
@@ -861,10 +862,11 @@ class User:
                """Have the active avatar leave the world."""
                if self.avatar:
                        current = self.avatar.get("location")
-                       self.avatar.set("default_location", current)
-                       self.avatar.echo_to_location("You suddenly wonder where " + self.avatar.get("name") + " went.")
-                       del universe.contents[current].contents[self.avatar.key]
-                       self.avatar.remove_facet("location")
+                       if current:
+                               self.avatar.set("default_location", current)
+                               self.avatar.echo_to_location("You suddenly wonder where " + self.avatar.get("name") + " went.")
+                               del universe.contents[current].contents[self.avatar.key]
+                               self.avatar.remove_facet("location")
                        self.avatar.owner = None
                        self.avatar = None
 
@@ -877,22 +879,6 @@ class User:
                """List names of assigned avatars."""
                return [ universe.contents[avatar].get("name") for avatar in self.account.getlist("avatars") ]
 
-def create_pidfile(universe):
-       """Write a file containing the current process ID."""
-       pid = str(getpid())
-       log("Process ID: " + pid)
-       file_name = universe.contents["internal:process"].get("pidfile")
-       if file_name:
-               file_descriptor = file(file_name, 'w')
-               file_descriptor.write(pid + "\n")
-               file_descriptor.flush()
-               file_descriptor.close()
-
-def remove_pidfile(universe):
-       """Remove the file containing the current process ID."""
-       file_name = universe.contents["internal:process"].get("pidfile")
-       if file_name and access(file_name, W_OK): remove(file_name)
-
 def makelist(value):
        """Turn string into list type."""
        if value[0] + value[-1] == "[]": return eval(value)
@@ -922,6 +908,8 @@ def log(message, level=0):
 
        # send the timestamp and line to a file
        if file_name:
+               if not isabs(file_name):
+                       file_name = path_join(universe.startdir, file_name)
                file_descriptor = file(file_name, "a")
                for line in lines: file_descriptor.write(timestamp + " " + line + "\n")
                file_descriptor.flush()
@@ -976,8 +964,8 @@ def get_loglines(level, start, stop):
                message = "There are " + str(total_count)
                message += " log lines in memory and " + str(filtered_count)
                message += " at or above level " + str(level) + "."
-               message += " The lines from " + str(stop) + " to " + str(start)
-               message += " are:$(eol)$(eol)"
+               message += " The matching lines from " + str(stop) + " to "
+               message += str(start) + " are:$(eol)$(eol)"
 
                # add the text from the selected lines
                if stop > 1: range_lines = loglines[-start:-(stop-1)]
@@ -1163,13 +1151,12 @@ def escape_macros(text):
        """Escapes replacement macros in text."""
        return text.replace("$(", "$_(")
 
-def check_time(frequency):
-       """Check for a factor of the current increment count."""
-       if type(frequency) is str:
-               frequency = universe.categories["internal"]["time"].getint(frequency)
-       if not "counters" in universe.categories["internal"]:
-               Element("internal:counters", universe)
-       return not universe.categories["internal"]["counters"].getint("elapsed") % frequency
+def first_word(text, separator=" "):
+       """Returns a tuple of the first word and the rest."""
+       if text:
+               if text.find(separator) > 0: return text.split(separator, 1)
+               else: return text, ""
+       else: return "", ""
 
 def on_pulse():
        """The things which should happen on each pulse, aside from reloads."""
@@ -1185,13 +1172,21 @@ def on_pulse():
        # iterate over the connected users
        for user in universe.userlist: user.pulse()
 
+       # add an element for counters if it doesn't exist
+       if not "counters" in universe.categories["internal"]:
+               universe.categories["internal"]["counters"] = Element("internal:counters", universe)
+
        # update the log every now and then
-       if check_time("frequency_log"):
+       if not universe.categories["internal"]["counters"].getint("mark"):
                log(str(len(universe.userlist)) + " connection(s)")
+               universe.categories["internal"]["counters"].set("mark", universe.categories["internal"]["time"].getint("frequency_log"))
+       else: universe.categories["internal"]["counters"].set("mark", universe.categories["internal"]["counters"].getint("mark") - 1)
 
        # periodically save everything
-       if check_time("frequency_save"):
+       if not universe.categories["internal"]["counters"].getint("save"):
                universe.save()
+               universe.categories["internal"]["counters"].set("save", universe.categories["internal"]["time"].getint("frequency_save"))
+       else: universe.categories["internal"]["counters"].set("save", universe.categories["internal"]["counters"].getint("save") - 1)
 
        # pause for a configurable amount of time (decimal seconds)
        sleep(universe.categories["internal"]["time"].getfloat("increment"))
@@ -1403,6 +1398,9 @@ def get_choice_action(user, choice):
 def handle_user_input(user):
        """The main handler, branches to a state-specific handler."""
 
+       # if the user's client echo is off, send a blank line for aesthetics
+       if user.echoing: user.received_newline = True
+
        # check to make sure the state is expected, then call that handler
        if "handler_" + user.state in globals():
                exec("handler_" + user.state + "(user)")
@@ -1412,9 +1410,6 @@ def handle_user_input(user):
        # since we got input, flag that the menu/prompt needs to be redisplayed
        user.menu_seen = False
 
-       # if the user's client echo is off, send a blank line for aesthetics
-       if user.echoing: user.received_newline = True
-
 def generic_menu_handler(user):
        """A generic menu choice handler."""
 
@@ -1546,12 +1541,16 @@ def handler_active(user):
        # is there input?
        if input_data:
 
-               # split out the command (first word) and parameters (everything else)
-               if input_data.find(" ") > 0:
-                       command_name, parameters = input_data.split(" ", 1)
+               # split out the command and parameters
+               actor = user.avatar
+               mode = actor.get("mode")
+               if mode and input_data.startswith("!"):
+                       command_name, parameters = first_word(input_data[1:])
+               elif mode == "chat":
+                       command_name = "say"
+                       parameters = input_data
                else:
-                       command_name = input_data
-                       parameters = ""
+                       command_name, parameters = first_word(input_data)
 
                # lowercase the command
                command_name = command_name.lower()
@@ -1562,7 +1561,6 @@ def handler_active(user):
                else: command = None
 
                # if it's allowed, do it
-               actor = user.avatar
                if actor.can_run(command): exec(command.get("action"))
 
                # otherwise, give an error
@@ -1584,7 +1582,7 @@ def command_halt(actor, parameters):
                log(message, 8)
 
                # set a flag to terminate the world
-               universe.terminate_world = True
+               universe.terminate_flag = True
 
 def command_reload(actor):
        """Reload all code modules, configs and data."""
@@ -1595,7 +1593,7 @@ def command_reload(actor):
                log("User " + actor.owner.account.get("name") + " reloaded the world.", 8)
 
                # set a flag to reload
-               universe.reload_modules = True
+               universe.reload_flag = True
 
 def command_quit(actor):
        """Leave the world and go back to the main menu."""
@@ -1631,6 +1629,24 @@ def command_help(actor, parameters):
                                help_text = "No help is provided for this command."
                        output += help_text
 
+                       # list related commands
+                       see_also = command.getlist("see_also")
+                       if see_also:
+                               really_see_also = ""
+                               for item in see_also:
+                                       if item in universe.categories["command"]:
+                                               command = universe.categories["command"][item]
+                                               if actor.can_run(command):
+                                                       if really_see_also:
+                                                               really_see_also += ", "
+                                                       if command.getboolean("administrative"):
+                                                               really_see_also += "$(red)"
+                                                       else:
+                                                               really_see_also += "$(grn)"
+                                                       really_see_also += item + "$(nrm)"
+                               if really_see_also:
+                                       output += "$(eol)$(eol)See also: " + really_see_also
+
                # no data for the requested command word
                else:
                        output = "That is not an available command."
@@ -1677,35 +1693,40 @@ def command_say(actor, parameters):
        # the user entered a message
        elif parameters:
 
-               # get rid of quote marks on the ends of the message and
-               # capitalize the first letter
+               # get rid of quote marks on the ends of the message
                message = parameters.strip("\"'`")
-               message = message[0].capitalize() + message[1:]
-
-               # a dictionary of punctuation:action pairs
-               actions = {}
-               for facet in universe.categories["internal"]["language"].facets():
-                       if facet.startswith("punctuation_"):
-                               action = facet.split("_")[1]
-                               for mark in universe.categories["internal"]["language"].getlist(facet):
-                                               actions[mark] = action
 
                # match the punctuation used, if any, to an action
+               actions = universe.categories["internal"]["language"].getdict("actions")
                default_punctuation = universe.categories["internal"]["language"].get("default_punctuation")
-               action = actions[default_punctuation]
+               action = ""
                for mark in actions.keys():
-                       if message.endswith(mark) and mark != default_punctuation:
+                       if message.endswith(mark):
                                action = actions[mark]
                                break
 
-               # if the action is default and there is no mark, add one
-               if action == actions[default_punctuation] and not message.endswith(default_punctuation):
-                       message += default_punctuation
+               # add punctuation if needed
+               if not action:
+                       action = actions[default_punctuation]
+                       if message and not message[-1] in punctuation:
+                               message += default_punctuation
+
+               # decapitalize the first letter to improve matching
+               if message and message[0] in uppercase:
+                       message = message[0].lower() + message[1:]
+
+               # iterate over all words in message, replacing typos
+               typos = universe.categories["internal"]["language"].getdict("typos")
+               words = message.split()
+               for index in range(len(words)):
+                       word = words[index]
+                       bare_word = word.strip(punctuation)
+                       if bare_word in typos.keys():
+                               words[index] = word.replace(bare_word, typos[bare_word])
+               message = " ".join(words)
 
-               # capitalize a list of words within the message
-               capitalize_words = universe.categories["internal"]["language"].getlist("capitalize_words")
-               for word in capitalize_words:
-                       message = message.replace(" " + word + " ", " " + word.capitalize() + " ")
+               # capitalize the first letter
+               message = message[0].upper() + message[1:]
 
                # tell the room
                actor.echo_to_location(actor.get("name") + " " + action + "s, \"" + message + "\"")
@@ -1715,6 +1736,17 @@ def command_say(actor, parameters):
        else:
                actor.send("What do you want to say?")
 
+def command_chat(actor):
+       """Toggle chat mode."""
+       mode = actor.get("mode")
+       if not mode:
+               actor.set("mode", "chat")
+               actor.send("Entering chat mode (use $(grn)!chat$(nrm) to exit).")
+       elif mode == "chat":
+               actor.remove_facet("mode")
+               actor.send("Exiting chat mode.")
+       else: actor.send("Sorry, but you're already busy with something else!")
+
 def command_show(actor, parameters):
        """Show program data."""
        message = ""
@@ -1785,6 +1817,8 @@ def command_show(actor, parameters):
                        if match("^\d+$", arguments[1]) and 0 <= int(arguments[1]) <= 9:
                                level = int(arguments[1])
                        else: level = -1
+               elif 0 <= actor.owner.account.getint("loglevel") <= 9:
+                       level = actor.owner.account.getint("loglevel")
                else: level = 1
                if level > -1 and start > -1 and stop > -1:
                        message = get_loglines(level, start, stop)
@@ -1875,13 +1909,88 @@ def daemonize():
        """Fork and disassociate from everything."""
        if universe.contents["internal:process"].getboolean("daemon"):
                import sys
+               from resource import getrlimit, RLIMIT_NOFILE
                log("Disassociating from the controlling terminal.")
                if fork(): _exit(0)
                setsid()
+               if fork(): _exit(0)
+               chdir("/")
+               umask(0)
                for stdpipe in range(3): close(stdpipe)
                sys.stdin = sys.__stdin__ = file("/dev/null", "r")
                sys.stdout = sys.stderr = sys.__stdout__ = sys.__stderr__ = file("/dev/null", "w")
 
+def create_pidfile(universe):
+       """Write a file containing the current process ID."""
+       pid = str(getpid())
+       log("Process ID: " + pid)
+       file_name = universe.contents["internal:process"].get("pidfile")
+       if file_name:
+               if not isabs(file_name):
+                       file_name = path_join(universe.startdir, file_name)
+               file_descriptor = file(file_name, 'w')
+               file_descriptor.write(pid + "\n")
+               file_descriptor.flush()
+               file_descriptor.close()
+
+def remove_pidfile(universe):
+       """Remove the file containing the current process ID."""
+       file_name = universe.contents["internal:process"].get("pidfile")
+       if file_name:
+               if not isabs(file_name):
+                       file_name = path_join(universe.startdir, file_name)
+               if access(file_name, W_OK): remove(file_name)
+
+def excepthook(excepttype, value, traceback):
+       """Handle uncaught exceptions."""
+
+       # assemble the list of errors into a single string
+       message = "".join(format_exception(excepttype, value, traceback))
+
+       # try to log it, if possible
+       try: log(message, 9)
+       except: pass
+
+       # try to write it to stderr, if possible
+       try: stderr.write(message)
+       except: pass
+
+def sighook(what, where):
+       """Handle external signals."""
+
+       # a generic message
+       message = "Caught signal: "
+
+       # for a hangup signal
+       if what == SIGHUP:
+               message += "hangup (reloading)"
+               universe.reload_flag = True
+
+       # for a terminate signal
+       elif what == SIGTERM:
+               message += "terminate (halting)"
+               universe.terminate_flag = True
+
+       # catchall for unexpected signals
+       else: message += str(what) + " (unhandled)"
+
+       # log what happened
+       log(message, 8)
+
+# redefine sys.excepthook with ours
+import sys
+sys.excepthook = excepthook
+
+# assign the sgnal handlers
+signal(SIGHUP, sighook)
+signal(SIGTERM, sighook)
+
 # if there is no universe, create an empty one
-if not "universe" in locals(): universe = Universe()
+if not "universe" in locals():
+       if len(argv) > 1: conffile = argv[1]
+       else: conffile = ""
+       universe = Universe(conffile, True)
+elif universe.reload_flag:
+       universe = universe.new()
+       reload_data()