1 """Miscellaneous functions for the mudpy engine."""
3 # Copyright (c) 2004-2021 mudpy authors. Permission to use, copy,
4 # modify, and distribute this software is granted under terms
5 # provided in the LICENSE file distributed with this software.
25 """An element of the universe."""
27 def __init__(self, key, universe, origin=None):
28 """Set up a new element."""
30 # keep track of our key name
33 # keep track of what universe it's loading into
34 self.universe = universe
36 # set of facet keys from the universe
37 self.facethash = dict()
39 # not owned by a user by default (used for avatars)
42 # no contents in here by default
45 if self.key.find(".") > 0:
46 self.group, self.subkey = self.key.split(".")[-2:]
49 self.subkey = self.key
50 if self.group not in self.universe.groups:
51 self.universe.groups[self.group] = {}
53 # get an appropriate origin
55 self.universe.add_group(self.group)
56 origin = self.universe.files[
57 self.universe.origins[self.group]["fallback"]]
59 # record or reset a pointer to the origin file
60 self.origin = self.universe.files[origin.source]
62 # add or replace this element in the universe
63 self.universe.contents[self.key] = self
64 self.universe.groups[self.group][self.subkey] = self
67 """Create a new element and replace this one."""
68 args = (self.key, self.universe, self.origin)
73 """Remove an element from the universe and destroy it."""
74 for facet in dict(self.facethash):
75 self.remove_facet(facet)
76 del self.universe.groups[self.group][self.subkey]
77 del self.universe.contents[self.key]
81 """Return a list of non-inherited facets for this element."""
84 def has_facet(self, facet):
85 """Return whether the non-inherited facet exists."""
86 return facet in self.facets()
88 def remove_facet(self, facet):
89 """Remove a facet from the element."""
90 if ".".join((self.key, facet)) in self.origin.data:
91 del self.origin.data[".".join((self.key, facet))]
92 if facet in self.facethash:
93 del self.facethash[facet]
94 self.origin.modified = True
97 """Return a list of the element's inheritance lineage."""
98 if self.has_facet("inherit"):
99 ancestry = self.get("inherit")
102 for parent in ancestry[:]:
103 ancestors = self.universe.contents[parent].ancestry()
104 for ancestor in ancestors:
105 if ancestor not in ancestry:
106 ancestry.append(ancestor)
111 def get(self, facet, default=None):
112 """Retrieve values."""
116 return self.origin.data[".".join((self.key, facet))]
117 except (KeyError, TypeError):
119 if self.has_facet("inherit"):
120 for ancestor in self.ancestry():
121 if self.universe.contents[ancestor].has_facet(facet):
122 return self.universe.contents[ancestor].get(facet)
126 def set(self, facet, value):
128 if not self.origin.is_writeable() and not self.universe.loading:
129 # break if there is an attempt to update an element from a
130 # read-only file, unless the universe is in the midst of loading
131 # updated data from files
132 raise PermissionError("Altering elements in read-only files is "
134 # Coerce some values to appropriate data types
135 # TODO(fungi) Move these to a separate validation mechanism
136 if facet in ["loglevel"]:
138 elif facet in ["administrator"]:
141 # The canonical node for this facet within its origin
142 node = ".".join((self.key, facet))
144 if node not in self.origin.data or self.origin.data[node] != value:
145 # Be careful to only update the origin's contents when required,
146 # since that affects whether the backing file gets written
147 self.origin.data[node] = value
148 self.origin.modified = True
150 # Make sure this facet is included in the element's facets
151 self.facethash[facet] = self.origin.data[node]
153 def append(self, facet, value):
154 """Append value to a list."""
155 newlist = self.get(facet)
158 if type(newlist) is not list:
159 newlist = list(newlist)
160 newlist.append(value)
161 self.set(facet, newlist)
171 add_terminator=False,
174 """Convenience method to pass messages to an owner."""
187 def is_restricted(self):
188 """Boolean check whether command is administrative or debugging."""
189 return bool(self.get("administrative") or self.get("debugging"))
192 """Boolean check whether an actor is controlled by an admin owner."""
193 return self.owner and self.owner.is_admin()
195 def can_run(self, command):
196 """Check if the user can run this command object."""
198 # has to be in the commands group
199 if command not in self.universe.groups["command"].values():
202 # debugging commands are not allowed outside debug mode
203 if command.get("debugging") and not self.universe.debug_mode():
206 # avatars of administrators can run any command
210 # everyone can run non-administrative commands
211 if not command.is_restricted():
214 # otherwise the command cannot be run by this actor
217 def update_location(self):
218 """Make sure the location's contents contain this element."""
219 area = self.get("location")
220 if area in self.universe.contents:
221 self.universe.contents[area].contents[self.key] = self
223 def clean_contents(self):
224 """Make sure the element's contents aren't bogus."""
225 for element in self.contents.values():
226 if element.get("location") != self.key:
227 del self.contents[element.key]
229 def go_to(self, area):
230 """Relocate the element to a specific area."""
231 current = self.get("location")
232 if current and self.key in self.universe.contents[current].contents:
233 del universe.contents[current].contents[self.key]
234 if area in self.universe.contents:
235 self.set("location", area)
236 self.universe.contents[area].contents[self.key] = self
240 """Relocate the element to its default location."""
241 self.go_to(self.get("default_location"))
242 self.echo_to_location(
243 "You suddenly realize that " + self.get("name") + " is here."
246 def move_direction(self, direction):
247 """Relocate the element in a specified direction."""
248 motion = self.universe.contents["mudpy.movement.%s" % direction]
249 enter_term = motion.get("enter_term")
250 exit_term = motion.get("exit_term")
251 self.echo_to_location("%s exits %s." % (self.get("name"), exit_term))
252 self.send("You exit %s." % exit_term, add_prompt=False)
254 self.universe.contents[
255 self.get("location")].link_neighbor(direction)
257 self.echo_to_location("%s arrives from %s." % (
258 self.get("name"), enter_term))
260 def look_at(self, key):
261 """Show an element to another element."""
263 element = self.universe.contents[key]
265 name = element.get("name")
267 message += "$(cyn)" + name + "$(nrm)$(eol)"
268 description = element.get("description")
270 message += description + "$(eol)"
271 portal_list = list(element.portals().keys())
274 message += "$(cyn)[ Exits: " + ", ".join(
277 for element in self.universe.contents[
280 if element.get("is_actor") and element is not self:
281 message += "$(yel)" + element.get(
283 ) + " is here.$(nrm)$(eol)"
284 elif element is not self:
285 message += "$(grn)" + element.get(
291 """Map the portal directions for an area to neighbors."""
293 if re.match(r"""^area\.-?\d+,-?\d+,-?\d+$""", self.key):
294 coordinates = [(int(x))
295 for x in self.key.split(".")[-1].split(",")]
298 self.universe.contents["mudpy.movement.%s" % x].get("vector")
299 ) for x in self.universe.directions)
300 for portal in self.get("gridlinks"):
301 adjacent = map(lambda c, o: c + o,
302 coordinates, offsets[portal])
303 neighbor = "area." + ",".join(
304 [(str(x)) for x in adjacent]
306 if neighbor in self.universe.contents:
307 portals[portal] = neighbor
308 for facet in self.facets():
309 if facet.startswith("link_"):
310 neighbor = self.get(facet)
311 if neighbor in self.universe.contents:
312 portal = facet.split("_")[1]
313 portals[portal] = neighbor
316 def link_neighbor(self, direction):
317 """Return the element linked in a given direction."""
318 portals = self.portals()
319 if direction in portals:
320 return portals[direction]
322 def echo_to_location(self, message):
323 """Show a message to other elements in the current location."""
324 for element in self.universe.contents[
327 if element is not self:
328 element.send(message)
335 def __init__(self, filename="", load=False):
336 """Initialize the universe."""
339 self.directions = set()
343 self.reload_flag = False
344 self.setup_loglines = []
345 self.startdir = os.getcwd()
346 self.terminate_flag = False
350 possible_filenames = [
352 "/usr/local/mudpy/etc/mudpy.yaml",
353 "/usr/local/etc/mudpy.yaml",
354 "/etc/mudpy/mudpy.yaml",
357 for filename in possible_filenames:
358 if os.access(filename, os.R_OK):
360 if not os.path.isabs(filename):
361 filename = os.path.join(self.startdir, filename)
362 self.filename = filename
364 # make sure to preserve any accumulated log entries during load
365 self.setup_loglines += self.load()
368 """Load universe data from persistent storage."""
370 # while loading, it's safe to update elements from read-only files
373 # it's possible for this to enter before logging configuration is read
374 pending_loglines = []
376 # start populating the (re)files dict from the base config
378 mudpy.data.Data(self.filename, self)
380 # load default storage locations for groups
381 if hasattr(self, "contents") and "mudpy.filing" in self.contents:
382 self.origins.update(self.contents["mudpy.filing"].get(
385 # add some builtin groups we know we'll need
386 for group in ("account", "actor", "internal"):
387 self.add_group(group)
389 # make a list of inactive avatars
390 inactive_avatars = []
391 for account in self.groups.get("account", {}).values():
392 for avatar in account.get("avatars"):
394 inactive_avatars.append(self.contents[avatar])
396 pending_loglines.append((
397 'Missing avatar "%s", possible data corruption' %
399 for user in self.userlist:
400 if user.avatar in inactive_avatars:
401 inactive_avatars.remove(user.avatar)
403 # another pass to straighten out all the element contents
404 for element in self.contents.values():
405 element.update_location()
406 element.clean_contents()
408 # warn when debug mode has been engaged
409 if self.debug_mode():
410 pending_loglines.append((
411 "WARNING: Unsafe debugging mode is enabled!", 6))
413 # done loading, so disallow updating elements from read-only files
416 return pending_loglines
419 """Create a new, empty Universe (the Big Bang)."""
420 new_universe = Universe()
421 for attribute in vars(self).keys():
422 setattr(new_universe, attribute, getattr(self, attribute))
423 new_universe.reload_flag = False
428 """Save the universe to persistent storage."""
429 for key in self.files:
430 self.files[key].save()
432 def initialize_server_socket(self):
433 """Create and open the listening socket."""
435 # need to know the local address and port number for the listener
436 host = self.contents["mudpy.network"].get("host")
437 port = self.contents["mudpy.network"].get("port")
439 # if no host was specified, bind to the loopback address (preferring
447 # figure out if this is ipv4 or v6
448 family = socket.getaddrinfo(host, port)[0][0]
449 if family is socket.AF_INET6 and not socket.has_ipv6:
450 log("No support for IPv6 address %s (use IPv4 instead)." % host)
452 # create a new stream-type socket object
453 self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
455 # set the socket options to allow existing open ones to be
456 # reused (fixes a bug where the server can't bind for a minute
457 # when restarting on linux systems)
458 self.listening_socket.setsockopt(
459 socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
462 # bind the socket to to our desired server ipa and port
463 self.listening_socket.bind((host, port))
465 # disable blocking so we can proceed whether or not we can
467 self.listening_socket.setblocking(0)
469 # start listening on the socket
470 self.listening_socket.listen(1)
472 # note that we're now ready for user connections
473 log("Listening for Telnet connections on %s port %s" % (
477 """Convenience method to get the elapsed time counter."""
478 return self.groups["internal"]["counters"].get("elapsed")
480 def add_group(self, group, fallback=None):
481 """Set up group tracking/metadata."""
482 if group not in self.origins:
483 self.origins[group] = {}
485 fallback = mudpy.data.find_file(
486 ".".join((group, "yaml")), universe=self)
487 if "fallback" not in self.origins[group]:
488 self.origins[group]["fallback"] = fallback
489 flags = self.origins[group].get("flags", None)
490 if fallback not in self.files:
491 mudpy.data.Data(fallback, self, flags=flags)
493 def debug_mode(self):
494 """Boolean method to indicate whether unsafe debugging is enabled."""
495 return self.groups["mudpy"]["limit"].get("debug", False)
500 """This is a connected user."""
503 """Default values for the in-memory user variables."""
506 self.authenticated = False
510 self.connection = None
512 self.input_queue = []
513 self.last_address = ""
514 self.last_input = universe.get_time()
515 self.menu_choices = {}
516 self.menu_seen = False
517 self.negotiation_pause = 0
518 self.output_queue = []
519 self.partial_input = b""
520 self.password_tries = 0
522 self.state = "telopt_negotiation"
525 self.universe = universe
528 """Log, close the connection and remove."""
530 name = self.account.get("name", self)
533 log("Logging out %s" % name, 2)
534 self.deactivate_avatar()
535 self.connection.close()
538 def check_idle(self):
539 """Warn or disconnect idle users as appropriate."""
540 idletime = universe.get_time() - self.last_input
541 linkdead_dict = universe.contents[
542 "mudpy.timing.idle.disconnect"].facets()
543 if self.state in linkdead_dict:
544 linkdead_state = self.state
546 linkdead_state = "default"
547 if idletime > linkdead_dict[linkdead_state]:
549 "$(eol)$(red)You've done nothing for far too long... goodbye!"
554 logline = "Disconnecting "
555 if self.account and self.account.get("name"):
556 logline += self.account.get("name")
558 logline += "an unknown user"
559 logline += (" after idling too long in the " + self.state
562 self.state = "disconnecting"
563 self.menu_seen = False
564 idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
565 if self.state in idle_dict:
566 idle_state = self.state
568 idle_state = "default"
569 if idletime == idle_dict[idle_state]:
571 "$(eol)$(red)If you continue to be unproductive, "
572 + "you'll be shown the door...$(nrm)$(eol)"
576 """Save, load a new user and relocate the connection."""
578 # copy old attributes
579 attributes = self.__dict__
581 # get out of the list
584 # get rid of the old user object
587 # create a new user object
590 # set everything equivalent
591 new_user.__dict__ = attributes
593 # the avatar needs a new owner
595 new_user.account = universe.contents[new_user.account.key]
596 new_user.avatar = universe.contents[new_user.avatar.key]
597 new_user.avatar.owner = new_user
600 universe.userlist.append(new_user)
602 def replace_old_connections(self):
603 """Disconnect active users with the same name."""
605 # the default return value
608 # iterate over each user in the list
609 for old_user in universe.userlist:
611 # the name is the same but it's not us
614 ) and old_user.account and old_user.account.get(
616 ) == self.account.get(
618 ) and old_user is not self:
622 "User " + self.account.get(
624 ) + " reconnected--closing old connection to "
625 + old_user.address + ".",
629 "$(eol)$(red)New connection from " + self.address
630 + ". Terminating old connection...$(nrm)$(eol)",
635 # close the old connection
636 old_user.connection.close()
638 # replace the old connection with this one
640 "$(eol)$(red)Taking over old connection from "
641 + old_user.address + ".$(nrm)"
643 old_user.connection = self.connection
644 old_user.last_address = old_user.address
645 old_user.address = self.address
647 # take this one out of the list and delete
653 # true if an old connection was replaced, false if not
656 def authenticate(self):
657 """Flag the user as authenticated and disconnect duplicates."""
658 if self.state != "authenticated":
659 self.authenticated = True
660 log("User %s authenticated for account %s." % (
661 self, self.account.subkey), 2)
662 if ("mudpy.limit" in universe.contents and self.account.subkey in
663 universe.contents["mudpy.limit"].get("admins")):
664 self.account.set("administrator", True)
665 log("Account %s is an administrator." % (
666 self.account.subkey), 2)
669 """Send the user their current menu."""
670 if not self.menu_seen:
671 self.menu_choices = get_menu_choices(self)
673 get_menu(self.state, self.error, self.menu_choices),
677 self.menu_seen = True
679 self.adjust_echoing()
682 """"Generate and return an input prompt."""
684 # Start with the user's preference, if one was provided
685 prompt = self.account.get("prompt")
687 # If the user has not set a prompt, then immediately return the default
688 # provided for the current state
690 return get_menu_prompt(self.state)
692 # Allow including the World clock state
693 if "$_(time)" in prompt:
694 prompt = prompt.replace(
696 str(universe.groups["internal"]["counters"].get("elapsed")))
698 # Append a single space for clear separation from user input
699 if prompt[-1] != " ":
700 prompt = "%s " % prompt
702 # Return the cooked prompt
705 def adjust_echoing(self):
706 """Adjust echoing to match state menu requirements."""
707 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
709 if menu_echo_on(self.state):
710 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
712 elif not menu_echo_on(self.state):
713 mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
717 """Remove a user from the list of connected users."""
718 log("Disconnecting account %s." % self, 0)
719 universe.userlist.remove(self)
729 add_terminator=False,
732 """Send arbitrary text to a connected user."""
734 # unless raw mode is on, clean it up all nice and pretty
737 # strip extra $(eol) off if present
738 while output.startswith("$(eol)"):
740 while output.endswith("$(eol)"):
742 extra_lines = output.find("$(eol)$(eol)$(eol)")
743 while extra_lines > -1:
744 output = output[:extra_lines] + output[extra_lines + 6:]
745 extra_lines = output.find("$(eol)$(eol)$(eol)")
747 # start with a newline, append the message, then end
748 # with the optional eol string passed to this function
749 # and the ansi escape to return to normal text
750 if not just_prompt and prepend_padding:
751 if (not self.output_queue or not
752 self.output_queue[-1].endswith(b"\r\n")):
753 output = "$(eol)" + output
754 elif not self.output_queue[-1].endswith(
756 ) and not self.output_queue[-1].endswith(
759 output = "$(eol)" + output
760 output += eol + chr(27) + "[0m"
762 # tack on a prompt if active
763 if self.state == "active":
767 output += self.prompt()
768 mode = self.avatar.get("mode")
770 output += "(" + mode + ") "
772 # find and replace macros in the output
773 output = replace_macros(self, output)
775 # wrap the text at the client's width (min 40, 0 disables)
777 if self.columns < 40:
781 output = wrap_ansi_text(output, wrap)
783 # if supported by the client, encode it utf-8
784 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
786 encoded_output = output.encode("utf-8")
788 # otherwise just send ascii
790 encoded_output = output.encode("ascii", "replace")
792 # end with a terminator if requested
793 if add_prompt or add_terminator:
794 if mudpy.telnet.is_enabled(
795 self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
796 encoded_output += mudpy.telnet.telnet_proto(
797 mudpy.telnet.IAC, mudpy.telnet.EOR)
798 elif not mudpy.telnet.is_enabled(
799 self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
800 encoded_output += mudpy.telnet.telnet_proto(
801 mudpy.telnet.IAC, mudpy.telnet.GA)
803 # and tack it onto the queue
804 self.output_queue.append(encoded_output)
806 # if this is urgent, flush all pending output
810 # just dump raw bytes as requested
812 self.output_queue.append(output)
816 """All the things to do to the user per increment."""
818 # if the world is terminating, disconnect
819 if universe.terminate_flag:
820 self.state = "disconnecting"
821 self.menu_seen = False
823 # check for an idle connection and act appropriately
827 # ask the client for their current terminal type (RFC 1091); it's None
828 # if it's not been initialized, the empty string if it has but the
829 # output was indeterminate, "UNKNOWN" if the client specified it has no
830 # terminal types to supply
831 if self.ttype is None:
832 mudpy.telnet.request_ttype(self)
834 # if output is paused, decrement the counter
835 if self.state == "telopt_negotiation":
836 if self.negotiation_pause:
837 self.negotiation_pause -= 1
839 self.state = "entering_account_name"
841 # show the user a menu as needed
842 elif not self.state == "active":
845 # flush any pending output in the queue
848 # disconnect users with the appropriate state
849 if self.state == "disconnecting":
852 # check for input and add it to the queue
855 # there is input waiting in the queue
857 handle_user_input(self)
860 """Try to send the last item in the queue and remove it."""
861 if self.output_queue:
863 self.connection.send(self.output_queue[0])
864 except (BrokenPipeError, ConnectionResetError):
865 if self.account and self.account.get("name"):
866 account = self.account.get("name")
868 account = "an unknown user"
869 self.state = "disconnecting"
870 log("Disconnected while sending to %s." % account, 7)
871 del self.output_queue[0]
873 def enqueue_input(self):
874 """Process and enqueue any new input."""
876 # check for some input
878 raw_input = self.connection.recv(1024)
885 # tack this on to any previous partial
886 self.partial_input += raw_input
888 # reply to and remove any IAC negotiation codes
889 mudpy.telnet.negotiate_telnet_options(self)
891 # separate multiple input lines
892 new_input_lines = self.partial_input.split(b"\r\0")
893 if len(new_input_lines) == 1:
894 new_input_lines = new_input_lines[0].split(b"\r\n")
896 # if input doesn't end in a newline, replace the
897 # held partial input with the last line of it
899 self.partial_input.endswith(b"\r\0") or
900 self.partial_input.endswith(b"\r\n")):
901 self.partial_input = new_input_lines.pop()
903 # otherwise, chop off the extra null input and reset
904 # the held partial input
906 new_input_lines.pop()
907 self.partial_input = b""
909 # iterate over the remaining lines
910 for line in new_input_lines:
912 # strip off extra whitespace
915 # log non-printable characters remaining
916 if not mudpy.telnet.is_enabled(
917 self, mudpy.telnet.TELOPT_BINARY, mudpy.telnet.HIM):
918 asciiline = bytes([x for x in line if 32 <= x <= 126])
919 if line != asciiline:
920 logline = "Non-ASCII characters from "
921 if self.account and self.account.get("name"):
922 logline += self.account.get("name") + ": "
924 logline += "unknown user: "
925 logline += repr(line)
930 line = line.decode("utf-8")
931 except UnicodeDecodeError:
932 logline = "Non-UTF-8 sequence from "
933 if self.account and self.account.get("name"):
934 logline += self.account.get("name") + ": "
936 logline += "unknown user: "
937 logline += repr(line)
941 line = unicodedata.normalize("NFKC", line)
943 # put on the end of the queue
944 self.input_queue.append(line)
946 def new_avatar(self):
947 """Instantiate a new, unconfigured avatar for this user."""
949 while ("avatar_%s_%s" % (self.account.get("name"), counter)
950 in universe.groups.get("actor", {}).keys()):
952 self.avatar = Element(
953 "actor.avatar_%s_%s" % (self.account.get("name"), counter),
955 self.avatar.append("inherit", "archetype.avatar")
956 self.account.append("avatars", self.avatar.key)
957 log("Created new avatar %s for user %s." % (
958 self.avatar.key, self.account.get("name")), 0)
960 def delete_avatar(self, avatar):
961 """Remove an avatar from the world and from the user's list."""
962 if self.avatar is universe.contents[avatar]:
964 log("Deleting avatar %s for user %s." % (
965 avatar, self.account.get("name")), 0)
966 universe.contents[avatar].destroy()
967 avatars = self.account.get("avatars")
968 avatars.remove(avatar)
969 self.account.set("avatars", avatars)
971 def activate_avatar_by_index(self, index):
972 """Enter the world with a particular indexed avatar."""
973 self.avatar = universe.contents[
974 self.account.get("avatars")[index]]
975 self.avatar.owner = self
976 self.state = "active"
977 log("Activated avatar %s (%s)." % (
978 self.avatar.get("name"), self.avatar.key), 0)
979 self.avatar.go_home()
981 def deactivate_avatar(self):
982 """Have the active avatar leave the world."""
984 log("Deactivating avatar %s (%s) for user %s." % (
985 self.avatar.get("name"), self.avatar.key,
986 self.account.get("name")), 0)
987 current = self.avatar.get("location")
989 self.avatar.set("default_location", current)
990 self.avatar.echo_to_location(
991 "You suddenly wonder where " + self.avatar.get(
995 del universe.contents[current].contents[self.avatar.key]
996 self.avatar.remove_facet("location")
997 self.avatar.owner = None
1001 """Destroy the user and associated avatars."""
1002 for avatar in self.account.get("avatars"):
1003 self.delete_avatar(avatar)
1004 log("Destroying account %s for user %s." % (
1005 self.account.get("name"), self), 0)
1006 self.account.destroy()
1008 def list_avatar_names(self):
1009 """List names of assigned avatars."""
1011 for avatar in self.account.get("avatars"):
1013 avatars.append(universe.contents[avatar].get("name"))
1015 log('Missing avatar "%s", possible data corruption.' %
1020 """Boolean check whether user's account is an admin."""
1021 return self.account.get("administrator", False)
1024 def broadcast(message, add_prompt=True):
1025 """Send a message to all connected users."""
1026 for each_user in universe.userlist:
1027 each_user.send("$(eol)" + message, add_prompt=add_prompt)
1030 def log(message, level=0):
1031 """Log a message."""
1033 # a couple references we need
1034 if "mudpy.log" in universe.contents:
1035 file_name = universe.contents["mudpy.log"].get("file", "")
1036 max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
1037 syslog_name = universe.contents["mudpy.log"].get("syslog", "")
1042 timestamp = datetime.datetime.now().isoformat(' ')
1044 # turn the message into a list of nonempty lines
1045 lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1047 # send the timestamp and line to a file
1049 if not os.path.isabs(file_name):
1050 file_name = os.path.join(universe.startdir, file_name)
1051 os.makedirs(os.path.dirname(file_name), exist_ok=True)
1052 file_descriptor = codecs.open(file_name, "a", "utf-8")
1054 file_descriptor.write(timestamp + " " + line + "\n")
1055 file_descriptor.flush()
1056 file_descriptor.close()
1058 # send the timestamp and line to standard output
1059 if ("mudpy.log" in universe.contents and
1060 universe.contents["mudpy.log"].get("stdout")):
1062 print(timestamp + " " + line)
1064 # send the line to the system log
1067 syslog_name.encode("utf-8"),
1069 syslog.LOG_INFO | syslog.LOG_DAEMON
1075 # display to connected administrators
1076 for user in universe.userlist:
1078 user.state == "active"
1080 and user.account.get("loglevel", 0) <= level):
1081 # iterate over every line in the message
1085 "$(bld)$(red)" + timestamp + " "
1086 + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1087 user.send(full_message, flush=True)
1089 # add to the recent log list
1091 while 0 < len(universe.loglines) >= max_log_lines:
1092 del universe.loglines[0]
1093 universe.loglines.append((timestamp + " " + line, level))
1096 def get_loglines(level, start, stop):
1097 """Return a specific range of loglines filtered by level."""
1099 # filter the log lines
1100 loglines = [x for x in universe.loglines if x[1] >= level]
1102 # we need these in several places
1103 total_count = str(len(universe.loglines))
1104 filtered_count = len(loglines)
1106 # don't proceed if there are no lines
1109 # can't start before the beginning or at the end
1110 if start > filtered_count:
1111 start = filtered_count
1115 # can't stop before we start
1123 "There are %s log lines in memory and %s at or above level %s. "
1124 "The matching lines from %s to %s are:$(eol)$(eol)" %
1125 (total_count, filtered_count, level, stop, start))
1127 # add the text from the selected lines
1129 range_lines = loglines[-start:-(stop - 1)]
1131 range_lines = loglines[-start:]
1132 for line in range_lines:
1133 message += " (%s) %s$(eol)" % (
1134 line[1], line[0].replace("$(", "$_("))
1136 # there were no lines
1138 message = "None of the %s lines in memory matches your request." % (
1145 def glyph_columns(character):
1146 """Convenience function to return the column width of a glyph."""
1147 if unicodedata.east_asian_width(character) in "FW":
1153 def wrap_ansi_text(text, width):
1154 """Wrap text with arbitrary width while ignoring ANSI colors."""
1156 # the current position in the entire text string, including all
1157 # characters, printable or otherwise
1160 # the current text position relative to the beginning of the line,
1161 # ignoring color escape sequences
1164 # the absolute and relative positions of the most recent whitespace
1166 last_abs_whitespace = 0
1167 last_rel_whitespace = 0
1169 # whether the current character is part of a color escape sequence
1172 # normalize any potentially composited unicode before we count it
1173 text = unicodedata.normalize("NFKC", text)
1175 # iterate over each character from the beginning of the text
1176 for each_character in text:
1178 # the current character is the escape character
1179 if each_character == "\x1b" and not escape:
1183 # the current character is within an escape sequence
1186 if each_character == "m":
1187 # the current character is m, which terminates the
1191 # the current character is a space
1192 elif each_character == " ":
1193 last_abs_whitespace = abs_pos
1194 last_rel_whitespace = rel_pos
1196 # the current character is a newline, so reset the relative
1197 # position too (start a new line)
1198 elif each_character == "\n":
1200 last_abs_whitespace = abs_pos
1201 last_rel_whitespace = rel_pos
1203 # the current character meets the requested maximum line width, so we
1204 # need to wrap unless the current word is wider than the terminal (in
1205 # which case we let it do the wrapping instead)
1206 if last_rel_whitespace != 0 and (rel_pos > width or (
1207 rel_pos > width - 1 and glyph_columns(each_character) == 2)):
1209 # insert an eol in place of the last space
1210 text = (text[:last_abs_whitespace] + "\r\n" +
1211 text[last_abs_whitespace + 1:])
1213 # increase the absolute position because an eol is two
1214 # characters but the space it replaced was only one
1217 # now we're at the beginning of a new line, plus the
1218 # number of characters wrapped from the previous line
1219 rel_pos -= last_rel_whitespace
1220 last_rel_whitespace = 0
1222 # as long as the character is not a carriage return and the
1223 # other above conditions haven't been met, count it as a
1224 # printable character
1225 elif each_character != "\r":
1226 rel_pos += glyph_columns(each_character)
1227 if each_character in (" ", "\n"):
1228 last_abs_whitespace = abs_pos
1229 last_rel_whitespace = rel_pos
1231 # increase the absolute position for every character
1234 # return the newly-wrapped text
1238 def weighted_choice(data):
1239 """Takes a dict weighted by value and returns a random key."""
1241 # this will hold our expanded list of keys from the data
1244 # create the expanded list of keys
1245 for key in data.keys():
1246 for _count in range(data[key]):
1247 expanded.append(key)
1249 # return one at random
1250 # Allow the random.randrange() call in bandit since it's not used for
1251 # security/cryptographic purposes
1252 return random.choice(expanded) # nosec
1256 """Returns a random character name."""
1258 # the vowels and consonants needed to create romaji syllables
1287 # this dict will hold our weighted list of syllables
1290 # generate the list with an even weighting
1291 for consonant in consonants:
1292 for vowel in vowels:
1293 syllables[consonant + vowel] = 1
1295 # we'll build the name into this string
1298 # create a name of random length from the syllables
1299 # Allow the random.randrange() call in bandit since it's not used for
1300 # security/cryptographic purposes
1301 for _syllable in range(random.randrange(2, 6)): # nosec
1302 name += weighted_choice(syllables)
1304 # strip any leading quotemark, capitalize and return the name
1305 return name.strip("'").capitalize()
1308 def replace_macros(user, text, is_input=False):
1309 """Replaces macros in text output."""
1311 # third person pronouns
1313 "female": {"obj": "her", "pos": "hers", "sub": "she"},
1314 "male": {"obj": "him", "pos": "his", "sub": "he"},
1315 "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1318 # a dict of replacement macros
1321 "bld": chr(27) + "[1m",
1322 "nrm": chr(27) + "[0m",
1323 "blk": chr(27) + "[30m",
1324 "blu": chr(27) + "[34m",
1325 "cyn": chr(27) + "[36m",
1326 "grn": chr(27) + "[32m",
1327 "mgt": chr(27) + "[35m",
1328 "red": chr(27) + "[31m",
1329 "yel": chr(27) + "[33m",
1332 # add dynamic macros where possible
1334 account_name = user.account.get("name")
1336 macros["account"] = account_name
1338 avatar_gender = user.avatar.get("gender")
1340 macros["tpop"] = pronouns[avatar_gender]["obj"]
1341 macros["tppp"] = pronouns[avatar_gender]["pos"]
1342 macros["tpsp"] = pronouns[avatar_gender]["sub"]
1347 # find and replace per the macros dict
1348 macro_start = text.find("$(")
1349 if macro_start == -1:
1351 macro_end = text.find(")", macro_start) + 1
1352 macro = text[macro_start + 2:macro_end - 1]
1353 if macro in macros.keys():
1354 replacement = macros[macro]
1356 # this is how we handle local file inclusion (dangerous!)
1357 elif macro.startswith("inc:"):
1358 incfile = mudpy.data.find_file(macro[4:], universe=universe)
1359 if os.path.exists(incfile):
1360 incfd = codecs.open(incfile, "r", "utf-8")
1363 if line.endswith("\n") and not line.endswith("\r\n"):
1364 line = line.replace("\n", "\r\n")
1366 # lose the trailing eol
1367 replacement = replacement[:-2]
1370 log("Couldn't read included " + incfile + " file.", 7)
1372 # if we get here, log and replace it with null
1376 log("Unexpected replacement macro " +
1377 macro + " encountered.", 6)
1379 # and now we act on the replacement
1380 text = text.replace("$(" + macro + ")", replacement)
1382 # replace the look-like-a-macro sequence
1383 text = text.replace("$_(", "$(")
1388 def escape_macros(value):
1389 """Escapes replacement macros in text."""
1390 if type(value) is str:
1391 return value.replace("$(", "$_(")
1396 def first_word(text, separator=" "):
1397 """Returns a tuple of the first word and the rest."""
1399 if text.find(separator) > 0:
1400 return text.split(separator, 1)
1408 """The things which should happen on each pulse, aside from reloads."""
1410 # open the listening socket if it hasn't been already
1411 if not hasattr(universe, "listening_socket"):
1412 universe.initialize_server_socket()
1414 # assign a user if a new connection is waiting
1415 user = check_for_connection(universe.listening_socket)
1417 universe.userlist.append(user)
1419 # iterate over the connected users
1420 for user in universe.userlist:
1423 # add an element for counters if it doesn't exist
1424 if "counters" not in universe.groups.get("internal", {}):
1425 Element("internal.counters", universe)
1427 # update the log every now and then
1428 if not universe.groups["internal"]["counters"].get("mark"):
1429 log(str(len(universe.userlist)) + " connection(s)")
1430 universe.groups["internal"]["counters"].set(
1431 "mark", universe.contents["mudpy.timing"].get("status")
1434 universe.groups["internal"]["counters"].set(
1435 "mark", universe.groups["internal"]["counters"].get(
1440 # periodically save everything
1441 if not universe.groups["internal"]["counters"].get("save"):
1443 universe.groups["internal"]["counters"].set(
1444 "save", universe.contents["mudpy.timing"].get("save")
1447 universe.groups["internal"]["counters"].set(
1448 "save", universe.groups["internal"]["counters"].get(
1453 # pause for a configurable amount of time (decimal seconds)
1454 time.sleep(universe.contents["mudpy.timing"].get("increment"))
1456 # increase the elapsed increment counter
1457 universe.groups["internal"]["counters"].set(
1458 "elapsed", universe.groups["internal"]["counters"].get(
1465 """Reload all relevant objects."""
1467 old_userlist = universe.userlist[:]
1468 old_loglines = universe.loglines[:]
1469 for element in list(universe.contents.values()):
1471 pending_loglines = universe.load()
1472 new_loglines = universe.loglines[:]
1473 universe.loglines = old_loglines + new_loglines + pending_loglines
1474 for user in old_userlist:
1478 def check_for_connection(listening_socket):
1479 """Check for a waiting connection and return a new user object."""
1481 # try to accept a new connection
1483 connection, address = listening_socket.accept()
1484 except BlockingIOError:
1487 # note that we got one
1488 log("New connection from %s." % address[0], 2)
1490 # disable blocking so we can proceed whether or not we can send/receive
1491 connection.setblocking(0)
1493 # create a new user object
1495 log("Instantiated %s for %s." % (user, address[0]), 0)
1497 # associate this connection with it
1498 user.connection = connection
1500 # set the user's ipa from the connection's ipa
1501 user.address = address[0]
1503 # let the client know we WILL EOR (RFC 885)
1504 mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1505 user.negotiation_pause = 2
1507 # return the new user object
1511 def find_command(command_name):
1512 """Try to find a command by name or abbreviation."""
1514 # lowercase the command
1515 command_name = command_name.lower()
1518 if command_name in universe.groups["command"]:
1519 # the command matches a command word for which we have data
1520 command = universe.groups["command"][command_name]
1522 for candidate in sorted(universe.groups["command"]):
1523 if candidate.startswith(command_name) and not universe.groups[
1524 "command"][candidate].is_restricted():
1525 # the command matches the start of a command word and is not
1526 # restricted to administrators
1527 command = universe.groups["command"][candidate]
1532 def get_menu(state, error=None, choices=None):
1533 """Show the correct menu text to a user."""
1535 # make sure we don't reuse a mutable sequence by default
1539 # get the description or error text
1540 message = get_menu_description(state, error)
1542 # get menu choices for the current state
1543 message += get_formatted_menu_choices(state, choices)
1545 # try to get a prompt, if it was defined
1546 message += get_menu_prompt(state)
1548 # throw in the default choice, if it exists
1549 message += get_formatted_default_menu_choice(state)
1551 # display a message indicating if echo is off
1552 message += get_echo_message(state)
1554 # return the assembly of various strings defined above
1558 def menu_echo_on(state):
1559 """True if echo is on, false if it is off."""
1560 return universe.groups["menu"][state].get("echo", True)
1563 def get_echo_message(state):
1564 """Return a message indicating that echo is off."""
1565 if menu_echo_on(state):
1568 return "(won't echo) "
1571 def get_default_menu_choice(state):
1572 """Return the default choice for a menu."""
1573 return universe.groups["menu"][state].get("default")
1576 def get_formatted_default_menu_choice(state):
1577 """Default menu choice foratted for inclusion in a prompt string."""
1578 default_choice = get_default_menu_choice(state)
1580 return "[$(red)" + default_choice + "$(nrm)] "
1585 def get_menu_description(state, error):
1586 """Get the description or error text."""
1588 # an error condition was raised by the handler
1591 # try to get an error message matching the condition
1593 description = universe.groups[
1594 "menu"][state].get("error_" + error)
1596 description = "That is not a valid choice..."
1597 description = "$(red)" + description + "$(nrm)"
1599 # there was no error condition
1602 # try to get a menu description for the current state
1603 description = universe.groups["menu"][state].get("description")
1605 # return the description or error message
1607 description += "$(eol)$(eol)"
1611 def get_menu_prompt(state):
1612 """Try to get a prompt, if it was defined."""
1613 prompt = universe.groups["menu"][state].get("prompt")
1619 def get_menu_choices(user):
1620 """Return a dict of choice:meaning."""
1621 state = universe.groups["menu"][user.state]
1622 create_choices = state.get("create")
1624 choices = call_hook_function(create_choices, (user,))
1630 for facet in state.facets():
1631 if facet.startswith("demand_") and not call_hook_function(
1632 universe.groups["menu"][user.state].get(facet), (user,)):
1633 ignores.append(facet.split("_", 2)[1])
1634 elif facet.startswith("create_"):
1635 creates[facet] = facet.split("_", 2)[1]
1636 elif facet.startswith("choice_"):
1637 options[facet] = facet.split("_", 2)[1]
1638 for facet in creates.keys():
1639 if not creates[facet] in ignores:
1640 choices[creates[facet]] = call_hook_function(
1641 state.get(facet), (user,))
1642 for facet in options.keys():
1643 if not options[facet] in ignores:
1644 choices[options[facet]] = state.get(facet)
1648 def get_formatted_menu_choices(state, choices):
1649 """Returns a formatted string of menu choices."""
1651 choice_keys = list(choices.keys())
1653 for choice in choice_keys:
1654 choice_output += " [$(red)" + choice + "$(nrm)] " + choices[
1658 choice_output += "$(eol)"
1659 return choice_output
1662 def get_menu_branches(state):
1663 """Return a dict of choice:branch."""
1665 for facet in universe.groups["menu"][state].facets():
1666 if facet.startswith("branch_"):
1668 facet.split("_", 2)[1]
1669 ] = universe.groups["menu"][state].get(facet)
1673 def get_default_branch(state):
1674 """Return the default branch."""
1675 return universe.groups["menu"][state].get("branch")
1678 def get_choice_branch(user):
1679 """Returns the new state matching the given choice."""
1680 branches = get_menu_branches(user.state)
1681 if user.choice in branches.keys():
1682 return branches[user.choice]
1683 elif user.choice in user.menu_choices.keys():
1684 return get_default_branch(user.state)
1689 def get_menu_actions(state):
1690 """Return a dict of choice:branch."""
1692 for facet in universe.groups["menu"][state].facets():
1693 if facet.startswith("action_"):
1695 facet.split("_", 2)[1]
1696 ] = universe.groups["menu"][state].get(facet)
1700 def get_default_action(state):
1701 """Return the default action."""
1702 return universe.groups["menu"][state].get("action")
1705 def get_choice_action(user):
1706 """Run any indicated script for the given choice."""
1707 actions = get_menu_actions(user.state)
1708 if user.choice in actions.keys():
1709 return actions[user.choice]
1710 elif user.choice in user.menu_choices.keys():
1711 return get_default_action(user.state)
1716 def call_hook_function(fname, arglist):
1717 """Safely execute named function with supplied arguments, return result."""
1719 # all functions relative to mudpy package
1722 for component in fname.split("."):
1724 function = getattr(function, component)
1725 except AttributeError:
1726 log('Could not find mudpy.%s() for arguments "%s"'
1727 % (fname, arglist), 7)
1732 return function(*arglist)
1734 log('Calling mudpy.%s(%s) raised an exception...\n%s'
1735 % (fname, (*arglist,), traceback.format_exc()), 7)
1738 def handle_user_input(user):
1739 """The main handler, branches to a state-specific handler."""
1741 # if the user's client echo is off, send a blank line for aesthetics
1742 if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1744 user.send("", add_prompt=False, prepend_padding=False)
1746 # check to make sure the state is expected, then call that handler
1748 globals()["handler_" + user.state](user)
1750 generic_menu_handler(user)
1752 # since we got input, flag that the menu/prompt needs to be redisplayed
1753 user.menu_seen = False
1755 # update the last_input timestamp while we're at it
1756 user.last_input = universe.get_time()
1759 def generic_menu_handler(user):
1760 """A generic menu choice handler."""
1762 # get a lower-case representation of the next line of input
1763 if user.input_queue:
1764 user.choice = user.input_queue.pop(0)
1766 user.choice = user.choice.lower()
1770 user.choice = get_default_menu_choice(user.state)
1771 if user.choice in user.menu_choices:
1772 action = get_choice_action(user)
1774 call_hook_function(action, (user,))
1775 new_state = get_choice_branch(user)
1777 user.state = new_state
1779 user.error = "default"
1782 def handler_entering_account_name(user):
1783 """Handle the login account name."""
1785 # get the next waiting line of input
1786 input_data = user.input_queue.pop(0)
1788 # did the user enter anything?
1791 # keep only the first word and convert to lower-case
1792 name = input_data.lower()
1794 # fail if there are non-alphanumeric characters
1795 if name != "".join(filter(
1796 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1798 user.error = "bad_name"
1800 # if that account exists, time to request a password
1801 elif name in universe.groups.get("account", {}):
1802 user.account = universe.groups["account"][name]
1803 user.state = "checking_password"
1805 # otherwise, this could be a brand new user
1807 user.account = Element("account.%s" % name, universe)
1808 user.account.set("name", name)
1809 log("New user: " + name, 2)
1810 user.state = "checking_new_account_name"
1812 # if the user entered nothing for a name, then buhbye
1814 user.state = "disconnecting"
1817 def handler_checking_password(user):
1818 """Handle the login account password."""
1820 # get the next waiting line of input
1821 input_data = user.input_queue.pop(0)
1823 if "mudpy.limit" in universe.contents:
1824 max_password_tries = universe.contents["mudpy.limit"].get(
1825 "password_tries", 3)
1827 max_password_tries = 3
1829 # does the hashed input equal the stored hash?
1830 if mudpy.password.verify(input_data, user.account.get("passhash")):
1832 # if so, set the username and load from cold storage
1833 if not user.replace_old_connections():
1835 user.state = "main_utility"
1837 # if at first your hashes don't match, try, try again
1838 elif user.password_tries < max_password_tries - 1:
1839 user.password_tries += 1
1840 user.error = "incorrect"
1842 # we've exceeded the maximum number of password failures, so disconnect
1845 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1847 user.state = "disconnecting"
1850 def handler_entering_new_password(user):
1851 """Handle a new password entry."""
1853 # get the next waiting line of input
1854 input_data = user.input_queue.pop(0)
1856 if "mudpy.limit" in universe.contents:
1857 max_password_tries = universe.contents["mudpy.limit"].get(
1858 "password_tries", 3)
1860 max_password_tries = 3
1862 # make sure the password is strong--at least one upper, one lower and
1863 # one digit, seven or more characters in length
1864 if len(input_data) > 6 and len(
1865 list(filter(lambda x: x >= "0" and x <= "9", input_data))
1867 list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1869 list(filter(lambda x: x >= "a" and x <= "z", input_data))
1872 # hash and store it, then move on to verification
1873 user.account.set("passhash", mudpy.password.create(input_data))
1874 user.state = "verifying_new_password"
1876 # the password was weak, try again if you haven't tried too many times
1877 elif user.password_tries < max_password_tries - 1:
1878 user.password_tries += 1
1881 # too many tries, so adios
1884 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1886 user.account.destroy()
1887 user.state = "disconnecting"
1890 def handler_verifying_new_password(user):
1891 """Handle the re-entered new password for verification."""
1893 # get the next waiting line of input
1894 input_data = user.input_queue.pop(0)
1896 if "mudpy.limit" in universe.contents:
1897 max_password_tries = universe.contents["mudpy.limit"].get(
1898 "password_tries", 3)
1900 max_password_tries = 3
1902 # hash the input and match it to storage
1903 if mudpy.password.verify(input_data, user.account.get("passhash")):
1906 # the hashes matched, so go active
1907 if not user.replace_old_connections():
1908 user.state = "main_utility"
1910 # go back to entering the new password as long as you haven't tried
1912 elif user.password_tries < max_password_tries - 1:
1913 user.password_tries += 1
1914 user.error = "differs"
1915 user.state = "entering_new_password"
1917 # otherwise, sayonara
1920 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1922 user.account.destroy()
1923 user.state = "disconnecting"
1926 def handler_active(user):
1927 """Handle input for active users."""
1929 # get the next waiting line of input
1930 input_data = user.input_queue.pop(0)
1935 # split out the command and parameters
1937 mode = actor.get("mode")
1938 if mode and input_data.startswith("!"):
1939 command_name, parameters = first_word(input_data[1:])
1940 elif mode == "chat":
1941 command_name = "say"
1942 parameters = input_data
1944 command_name, parameters = first_word(input_data)
1946 # expand to an actual command
1947 command = find_command(command_name)
1949 # if it's allowed, do it
1951 if actor.can_run(command):
1952 action_fname = command.get("action", command.key)
1954 result = call_hook_function(action_fname, (actor, parameters))
1956 # if the command was not run, give an error
1958 mudpy.command.error(actor, input_data)
1960 # if no input, just idle back with a prompt
1962 user.send("", just_prompt=True)
1965 def daemonize(universe):
1966 """Fork and disassociate from everything."""
1968 # only if this is what we're configured to do
1969 if "mudpy.process" in universe.contents and universe.contents[
1970 "mudpy.process"].get("daemon"):
1972 # log before we start forking around, so the terminal gets the message
1973 log("Disassociating from the controlling terminal.")
1975 # fork off and die, so we free up the controlling terminal
1979 # switch to a new process group
1982 # fork some more, this time to free us from the old process group
1986 # reset the working directory so we don't needlessly tie up mounts
1989 # clear the file creation mask so we can bend it to our will later
1992 # redirect stdin/stdout/stderr and close off their former descriptors
1993 for stdpipe in range(3):
1995 sys.stdin = codecs.open("/dev/null", "r", "utf-8")
1996 sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
1997 sys.stdout = codecs.open("/dev/null", "w", "utf-8")
1998 sys.stderr = codecs.open("/dev/null", "w", "utf-8")
1999 sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
2000 sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
2003 def create_pidfile(universe):
2004 """Write a file containing the current process ID."""
2005 pid = str(os.getpid())
2006 log("Process ID: " + pid)
2007 if "mudpy.process" in universe.contents:
2008 file_name = universe.contents["mudpy.process"].get("pidfile", "")
2012 if not os.path.isabs(file_name):
2013 file_name = os.path.join(universe.startdir, file_name)
2014 os.makedirs(os.path.dirname(file_name), exist_ok=True)
2015 file_descriptor = codecs.open(file_name, "w", "utf-8")
2016 file_descriptor.write(pid + "\n")
2017 file_descriptor.flush()
2018 file_descriptor.close()
2021 def remove_pidfile(universe):
2022 """Remove the file containing the current process ID."""
2023 if "mudpy.process" in universe.contents:
2024 file_name = universe.contents["mudpy.process"].get("pidfile", "")
2028 if not os.path.isabs(file_name):
2029 file_name = os.path.join(universe.startdir, file_name)
2030 if os.access(file_name, os.W_OK):
2031 os.remove(file_name)
2034 def excepthook(excepttype, value, tracebackdata):
2035 """Handle uncaught exceptions."""
2037 # assemble the list of errors into a single string
2039 traceback.format_exception(excepttype, value, tracebackdata)
2042 # try to log it, if possible
2045 except Exception as e:
2046 # try to write it to stderr, if possible
2047 sys.stderr.write(message + "\nException while logging...\n%s" % e)
2050 def sighook(what, where):
2051 """Handle external signals."""
2054 message = "Caught signal: "
2056 # for a hangup signal
2057 if what == signal.SIGHUP:
2058 message += "hangup (reloading)"
2059 universe.reload_flag = True
2061 # for a terminate signal
2062 elif what == signal.SIGTERM:
2063 message += "terminate (halting)"
2064 universe.terminate_flag = True
2066 # catchall for unexpected signals
2068 message += str(what) + " (unhandled)"
2074 def override_excepthook():
2075 """Redefine sys.excepthook with our own."""
2076 sys.excepthook = excepthook
2079 def assign_sighook():
2080 """Assign a customized handler for some signals."""
2081 signal.signal(signal.SIGHUP, sighook)
2082 signal.signal(signal.SIGTERM, sighook)
2086 """This contains functions to be performed when starting the engine."""
2088 # see if a configuration file was specified
2089 if len(sys.argv) > 1:
2090 conffile = sys.argv[1]
2096 universe = Universe(conffile, True)
2098 # report any loglines which accumulated during setup
2099 for logline in universe.setup_loglines:
2101 universe.setup_loglines = []
2103 # fork and disassociate
2106 # override the default exception handler so we get logging first thing
2107 override_excepthook()
2109 # set up custom signal handlers
2113 create_pidfile(universe)
2115 # load and store diagnostic info
2116 universe.versions = mudpy.version.Versions("mudpy")
2118 # log startup diagnostic messages
2119 log("On %s at %s" % (universe.versions.python_version, sys.executable), 1)
2120 log("Import path: %s" % ", ".join(sys.path), 1)
2121 log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
2122 log("Other python packages: %s" % universe.versions.environment_text, 1)
2123 log("Running version: %s" % universe.versions.version, 1)
2124 log("Initial directory: %s" % universe.startdir, 1)
2125 log("Command line: %s" % " ".join(sys.argv), 1)
2127 # pass the initialized universe back
2132 """These are functions performed when shutting down the engine."""
2134 # the loop has terminated, so save persistent data
2137 # log a final message
2138 log("Shutting down now.")
2140 # get rid of the pidfile
2141 remove_pidfile(universe)