1 """Miscellaneous functions for the mudpy engine."""
3 # Copyright (c) 2004-2020 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 # done loading, so disallow updating elements from read-only files
411 return pending_loglines
414 """Create a new, empty Universe (the Big Bang)."""
415 new_universe = Universe()
416 for attribute in vars(self).keys():
417 setattr(new_universe, attribute, getattr(self, attribute))
418 new_universe.reload_flag = False
423 """Save the universe to persistent storage."""
424 for key in self.files:
425 self.files[key].save()
427 def initialize_server_socket(self):
428 """Create and open the listening socket."""
430 # need to know the local address and port number for the listener
431 host = self.contents["mudpy.network"].get("host")
432 port = self.contents["mudpy.network"].get("port")
434 # if no host was specified, bind to all local addresses (preferring
442 # figure out if this is ipv4 or v6
443 family = socket.getaddrinfo(host, port)[0][0]
444 if family is socket.AF_INET6 and not socket.has_ipv6:
445 log("No support for IPv6 address %s (use IPv4 instead)." % host)
447 # create a new stream-type socket object
448 self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
450 # set the socket options to allow existing open ones to be
451 # reused (fixes a bug where the server can't bind for a minute
452 # when restarting on linux systems)
453 self.listening_socket.setsockopt(
454 socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
457 # bind the socket to to our desired server ipa and port
458 self.listening_socket.bind((host, port))
460 # disable blocking so we can proceed whether or not we can
462 self.listening_socket.setblocking(0)
464 # start listening on the socket
465 self.listening_socket.listen(1)
467 # note that we're now ready for user connections
468 log("Listening for Telnet connections on %s port %s" % (
472 """Convenience method to get the elapsed time counter."""
473 return self.groups["internal"]["counters"].get("elapsed")
475 def add_group(self, group, fallback=None):
476 """Set up group tracking/metadata."""
477 if group not in self.origins:
478 self.origins[group] = {}
480 fallback = mudpy.data.find_file(
481 ".".join((group, "yaml")), universe=self)
482 if "fallback" not in self.origins[group]:
483 self.origins[group]["fallback"] = fallback
484 flags = self.origins[group].get("flags", None)
485 if fallback not in self.files:
486 mudpy.data.Data(fallback, self, flags=flags)
488 def debug_mode(self):
489 """Boolean method to indicate whether unsafe debugging is enabled."""
490 return self.groups["mudpy"]["limit"].get("debug", False)
495 """This is a connected user."""
498 """Default values for the in-memory user variables."""
501 self.authenticated = False
505 self.connection = None
507 self.input_queue = []
508 self.last_address = ""
509 self.last_input = universe.get_time()
510 self.menu_choices = {}
511 self.menu_seen = False
512 self.negotiation_pause = 0
513 self.output_queue = []
514 self.partial_input = b""
515 self.password_tries = 0
517 self.state = "telopt_negotiation"
520 self.universe = universe
523 """Log, close the connection and remove."""
525 name = self.account.get("name", self)
528 log("Logging out %s" % name, 2)
529 self.deactivate_avatar()
530 self.connection.close()
533 def check_idle(self):
534 """Warn or disconnect idle users as appropriate."""
535 idletime = universe.get_time() - self.last_input
536 linkdead_dict = universe.contents[
537 "mudpy.timing.idle.disconnect"].facets()
538 if self.state in linkdead_dict:
539 linkdead_state = self.state
541 linkdead_state = "default"
542 if idletime > linkdead_dict[linkdead_state]:
544 "$(eol)$(red)You've done nothing for far too long... goodbye!"
549 logline = "Disconnecting "
550 if self.account and self.account.get("name"):
551 logline += self.account.get("name")
553 logline += "an unknown user"
554 logline += (" after idling too long in the " + self.state
557 self.state = "disconnecting"
558 self.menu_seen = False
559 idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
560 if self.state in idle_dict:
561 idle_state = self.state
563 idle_state = "default"
564 if idletime == idle_dict[idle_state]:
566 "$(eol)$(red)If you continue to be unproductive, "
567 + "you'll be shown the door...$(nrm)$(eol)"
571 """Save, load a new user and relocate the connection."""
573 # copy old attributes
574 attributes = self.__dict__
576 # get out of the list
579 # get rid of the old user object
582 # create a new user object
585 # set everything equivalent
586 new_user.__dict__ = attributes
588 # the avatar needs a new owner
590 new_user.account = universe.contents[new_user.account.key]
591 new_user.avatar = universe.contents[new_user.avatar.key]
592 new_user.avatar.owner = new_user
595 universe.userlist.append(new_user)
597 def replace_old_connections(self):
598 """Disconnect active users with the same name."""
600 # the default return value
603 # iterate over each user in the list
604 for old_user in universe.userlist:
606 # the name is the same but it's not us
609 ) and old_user.account and old_user.account.get(
611 ) == self.account.get(
613 ) and old_user is not self:
617 "User " + self.account.get(
619 ) + " reconnected--closing old connection to "
620 + old_user.address + ".",
624 "$(eol)$(red)New connection from " + self.address
625 + ". Terminating old connection...$(nrm)$(eol)",
630 # close the old connection
631 old_user.connection.close()
633 # replace the old connection with this one
635 "$(eol)$(red)Taking over old connection from "
636 + old_user.address + ".$(nrm)"
638 old_user.connection = self.connection
639 old_user.last_address = old_user.address
640 old_user.address = self.address
642 # take this one out of the list and delete
648 # true if an old connection was replaced, false if not
651 def authenticate(self):
652 """Flag the user as authenticated and disconnect duplicates."""
653 if self.state != "authenticated":
654 self.authenticated = True
655 log("User %s authenticated for account %s." % (
656 self, self.account.subkey), 2)
657 if ("mudpy.limit" in universe.contents and self.account.subkey in
658 universe.contents["mudpy.limit"].get("admins")):
659 self.account.set("administrator", True)
660 log("Account %s is an administrator." % (
661 self.account.subkey), 2)
664 """Send the user their current menu."""
665 if not self.menu_seen:
666 self.menu_choices = get_menu_choices(self)
668 get_menu(self.state, self.error, self.menu_choices),
672 self.menu_seen = True
674 self.adjust_echoing()
677 """"Generate and return an input prompt."""
679 # Start with the user's preference, if one was provided
680 prompt = self.account.get("prompt")
682 # If the user has not set a prompt, then immediately return the default
683 # provided for the current state
685 return get_menu_prompt(self.state)
687 # Allow including the World clock state
688 if "$_(time)" in prompt:
689 prompt = prompt.replace(
691 str(universe.groups["internal"]["counters"].get("elapsed")))
693 # Append a single space for clear separation from user input
694 if prompt[-1] != " ":
695 prompt = "%s " % prompt
697 # Return the cooked prompt
700 def adjust_echoing(self):
701 """Adjust echoing to match state menu requirements."""
702 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
704 if menu_echo_on(self.state):
705 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
707 elif not menu_echo_on(self.state):
708 mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
712 """Remove a user from the list of connected users."""
713 log("Disconnecting account %s." % self, 0)
714 universe.userlist.remove(self)
724 add_terminator=False,
727 """Send arbitrary text to a connected user."""
729 # unless raw mode is on, clean it up all nice and pretty
732 # strip extra $(eol) off if present
733 while output.startswith("$(eol)"):
735 while output.endswith("$(eol)"):
737 extra_lines = output.find("$(eol)$(eol)$(eol)")
738 while extra_lines > -1:
739 output = output[:extra_lines] + output[extra_lines + 6:]
740 extra_lines = output.find("$(eol)$(eol)$(eol)")
742 # start with a newline, append the message, then end
743 # with the optional eol string passed to this function
744 # and the ansi escape to return to normal text
745 if not just_prompt and prepend_padding:
746 if (not self.output_queue or not
747 self.output_queue[-1].endswith(b"\r\n")):
748 output = "$(eol)" + output
749 elif not self.output_queue[-1].endswith(
751 ) and not self.output_queue[-1].endswith(
754 output = "$(eol)" + output
755 output += eol + chr(27) + "[0m"
757 # tack on a prompt if active
758 if self.state == "active":
762 output += self.prompt()
763 mode = self.avatar.get("mode")
765 output += "(" + mode + ") "
767 # find and replace macros in the output
768 output = replace_macros(self, output)
770 # wrap the text at the client's width (min 40, 0 disables)
772 if self.columns < 40:
776 output = wrap_ansi_text(output, wrap)
778 # if supported by the client, encode it utf-8
779 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
781 encoded_output = output.encode("utf-8")
783 # otherwise just send ascii
785 encoded_output = output.encode("ascii", "replace")
787 # end with a terminator if requested
788 if add_prompt or add_terminator:
789 if mudpy.telnet.is_enabled(
790 self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
791 encoded_output += mudpy.telnet.telnet_proto(
792 mudpy.telnet.IAC, mudpy.telnet.EOR)
793 elif not mudpy.telnet.is_enabled(
794 self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
795 encoded_output += mudpy.telnet.telnet_proto(
796 mudpy.telnet.IAC, mudpy.telnet.GA)
798 # and tack it onto the queue
799 self.output_queue.append(encoded_output)
801 # if this is urgent, flush all pending output
805 # just dump raw bytes as requested
807 self.output_queue.append(output)
811 """All the things to do to the user per increment."""
813 # if the world is terminating, disconnect
814 if universe.terminate_flag:
815 self.state = "disconnecting"
816 self.menu_seen = False
818 # check for an idle connection and act appropriately
822 # ask the client for their current terminal type (RFC 1091); it's None
823 # if it's not been initialized, the empty string if it has but the
824 # output was indeterminate, "UNKNOWN" if the client specified it has no
825 # terminal types to supply
826 if self.ttype is None:
827 mudpy.telnet.request_ttype(self)
829 # if output is paused, decrement the counter
830 if self.state == "telopt_negotiation":
831 if self.negotiation_pause:
832 self.negotiation_pause -= 1
834 self.state = "entering_account_name"
836 # show the user a menu as needed
837 elif not self.state == "active":
840 # flush any pending output in the queue
843 # disconnect users with the appropriate state
844 if self.state == "disconnecting":
847 # check for input and add it to the queue
850 # there is input waiting in the queue
852 handle_user_input(self)
855 """Try to send the last item in the queue and remove it."""
856 if self.output_queue:
858 self.connection.send(self.output_queue[0])
859 except (BrokenPipeError, ConnectionResetError):
860 if self.account and self.account.get("name"):
861 account = self.account.get("name")
863 account = "an unknown user"
864 self.state = "disconnecting"
865 log("Disconnected while sending to %s." % account, 7)
866 del self.output_queue[0]
868 def enqueue_input(self):
869 """Process and enqueue any new input."""
871 # check for some input
873 raw_input = self.connection.recv(1024)
880 # tack this on to any previous partial
881 self.partial_input += raw_input
883 # reply to and remove any IAC negotiation codes
884 mudpy.telnet.negotiate_telnet_options(self)
886 # separate multiple input lines
887 new_input_lines = self.partial_input.split(b"\r\0")
888 if len(new_input_lines) == 1:
889 new_input_lines = new_input_lines[0].split(b"\r\n")
891 # if input doesn't end in a newline, replace the
892 # held partial input with the last line of it
894 self.partial_input.endswith(b"\r\0") or
895 self.partial_input.endswith(b"\r\n")):
896 self.partial_input = new_input_lines.pop()
898 # otherwise, chop off the extra null input and reset
899 # the held partial input
901 new_input_lines.pop()
902 self.partial_input = b""
904 # iterate over the remaining lines
905 for line in new_input_lines:
907 # strip off extra whitespace
910 # log non-printable characters remaining
911 if not mudpy.telnet.is_enabled(
912 self, mudpy.telnet.TELOPT_BINARY, mudpy.telnet.HIM):
913 asciiline = bytes([x for x in line if 32 <= x <= 126])
914 if line != asciiline:
915 logline = "Non-ASCII characters from "
916 if self.account and self.account.get("name"):
917 logline += self.account.get("name") + ": "
919 logline += "unknown user: "
920 logline += repr(line)
925 line = line.decode("utf-8")
926 except UnicodeDecodeError:
927 logline = "Non-UTF-8 sequence from "
928 if self.account and self.account.get("name"):
929 logline += self.account.get("name") + ": "
931 logline += "unknown user: "
932 logline += repr(line)
936 line = unicodedata.normalize("NFKC", line)
938 # put on the end of the queue
939 self.input_queue.append(line)
941 def new_avatar(self):
942 """Instantiate a new, unconfigured avatar for this user."""
944 while ("avatar_%s_%s" % (self.account.get("name"), counter)
945 in universe.groups.get("actor", {}).keys()):
947 self.avatar = Element(
948 "actor.avatar_%s_%s" % (self.account.get("name"), counter),
950 self.avatar.append("inherit", "archetype.avatar")
951 self.account.append("avatars", self.avatar.key)
952 log("Created new avatar %s for user %s." % (
953 self.avatar.key, self.account.get("name")), 0)
955 def delete_avatar(self, avatar):
956 """Remove an avatar from the world and from the user's list."""
957 if self.avatar is universe.contents[avatar]:
959 log("Deleting avatar %s for user %s." % (
960 avatar, self.account.get("name")), 0)
961 universe.contents[avatar].destroy()
962 avatars = self.account.get("avatars")
963 avatars.remove(avatar)
964 self.account.set("avatars", avatars)
966 def activate_avatar_by_index(self, index):
967 """Enter the world with a particular indexed avatar."""
968 self.avatar = universe.contents[
969 self.account.get("avatars")[index]]
970 self.avatar.owner = self
971 self.state = "active"
972 log("Activated avatar %s (%s)." % (
973 self.avatar.get("name"), self.avatar.key), 0)
974 self.avatar.go_home()
976 def deactivate_avatar(self):
977 """Have the active avatar leave the world."""
979 log("Deactivating avatar %s (%s) for user %s." % (
980 self.avatar.get("name"), self.avatar.key,
981 self.account.get("name")), 0)
982 current = self.avatar.get("location")
984 self.avatar.set("default_location", current)
985 self.avatar.echo_to_location(
986 "You suddenly wonder where " + self.avatar.get(
990 del universe.contents[current].contents[self.avatar.key]
991 self.avatar.remove_facet("location")
992 self.avatar.owner = None
996 """Destroy the user and associated avatars."""
997 for avatar in self.account.get("avatars"):
998 self.delete_avatar(avatar)
999 log("Destroying account %s for user %s." % (
1000 self.account.get("name"), self), 0)
1001 self.account.destroy()
1003 def list_avatar_names(self):
1004 """List names of assigned avatars."""
1006 for avatar in self.account.get("avatars"):
1008 avatars.append(universe.contents[avatar].get("name"))
1010 log('Missing avatar "%s", possible data corruption.' %
1015 """Boolean check whether user's account is an admin."""
1016 return self.account.get("administrator", False)
1019 def broadcast(message, add_prompt=True):
1020 """Send a message to all connected users."""
1021 for each_user in universe.userlist:
1022 each_user.send("$(eol)" + message, add_prompt=add_prompt)
1025 def log(message, level=0):
1026 """Log a message."""
1028 # a couple references we need
1029 if "mudpy.log" in universe.contents:
1030 file_name = universe.contents["mudpy.log"].get("file", "")
1031 max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
1032 syslog_name = universe.contents["mudpy.log"].get("syslog", "")
1037 timestamp = datetime.datetime.now().isoformat(' ')
1039 # turn the message into a list of nonempty lines
1040 lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1042 # send the timestamp and line to a file
1044 if not os.path.isabs(file_name):
1045 file_name = os.path.join(universe.startdir, file_name)
1046 os.makedirs(os.path.dirname(file_name), exist_ok=True)
1047 file_descriptor = codecs.open(file_name, "a", "utf-8")
1049 file_descriptor.write(timestamp + " " + line + "\n")
1050 file_descriptor.flush()
1051 file_descriptor.close()
1053 # send the timestamp and line to standard output
1054 if ("mudpy.log" in universe.contents and
1055 universe.contents["mudpy.log"].get("stdout")):
1057 print(timestamp + " " + line)
1059 # send the line to the system log
1062 syslog_name.encode("utf-8"),
1064 syslog.LOG_INFO | syslog.LOG_DAEMON
1070 # display to connected administrators
1071 for user in universe.userlist:
1073 user.state == "active"
1075 and user.account.get("loglevel", 0) <= level):
1076 # iterate over every line in the message
1080 "$(bld)$(red)" + timestamp + " "
1081 + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1082 user.send(full_message, flush=True)
1084 # add to the recent log list
1086 while 0 < len(universe.loglines) >= max_log_lines:
1087 del universe.loglines[0]
1088 universe.loglines.append((level, timestamp + " " + line))
1091 def get_loglines(level, start, stop):
1092 """Return a specific range of loglines filtered by level."""
1094 # filter the log lines
1095 loglines = [x for x in universe.loglines if x[0] >= level]
1097 # we need these in several places
1098 total_count = str(len(universe.loglines))
1099 filtered_count = len(loglines)
1101 # don't proceed if there are no lines
1104 # can't start before the beginning or at the end
1105 if start > filtered_count:
1106 start = filtered_count
1110 # can't stop before we start
1117 message = "There are " + str(total_count)
1118 message += " log lines in memory and " + str(filtered_count)
1119 message += " at or above level " + str(level) + "."
1120 message += " The matching lines from " + str(stop) + " to "
1121 message += str(start) + " are:$(eol)$(eol)"
1123 # add the text from the selected lines
1125 range_lines = loglines[-start:-(stop - 1)]
1127 range_lines = loglines[-start:]
1128 for line in range_lines:
1129 message += " (" + str(line[0]) + ") " + line[1].replace(
1133 # there were no lines
1135 message = "None of the " + str(total_count)
1136 message += " lines in memory matches your request."
1142 def glyph_columns(character):
1143 """Convenience function to return the column width of a glyph."""
1144 if unicodedata.east_asian_width(character) in "FW":
1150 def wrap_ansi_text(text, width):
1151 """Wrap text with arbitrary width while ignoring ANSI colors."""
1153 # the current position in the entire text string, including all
1154 # characters, printable or otherwise
1157 # the current text position relative to the beginning of the line,
1158 # ignoring color escape sequences
1161 # the absolute and relative positions of the most recent whitespace
1163 last_abs_whitespace = 0
1164 last_rel_whitespace = 0
1166 # whether the current character is part of a color escape sequence
1169 # normalize any potentially composited unicode before we count it
1170 text = unicodedata.normalize("NFKC", text)
1172 # iterate over each character from the beginning of the text
1173 for each_character in text:
1175 # the current character is the escape character
1176 if each_character == "\x1b" and not escape:
1180 # the current character is within an escape sequence
1183 if each_character == "m":
1184 # the current character is m, which terminates the
1188 # the current character is a space
1189 elif each_character == " ":
1190 last_abs_whitespace = abs_pos
1191 last_rel_whitespace = rel_pos
1193 # the current character is a newline, so reset the relative
1194 # position too (start a new line)
1195 elif each_character == "\n":
1197 last_abs_whitespace = abs_pos
1198 last_rel_whitespace = rel_pos
1200 # the current character meets the requested maximum line width, so we
1201 # need to wrap unless the current word is wider than the terminal (in
1202 # which case we let it do the wrapping instead)
1203 if last_rel_whitespace != 0 and (rel_pos > width or (
1204 rel_pos > width - 1 and glyph_columns(each_character) == 2)):
1206 # insert an eol in place of the last space
1207 text = (text[:last_abs_whitespace] + "\r\n" +
1208 text[last_abs_whitespace + 1:])
1210 # increase the absolute position because an eol is two
1211 # characters but the space it replaced was only one
1214 # now we're at the beginning of a new line, plus the
1215 # number of characters wrapped from the previous line
1216 rel_pos -= last_rel_whitespace
1217 last_rel_whitespace = 0
1219 # as long as the character is not a carriage return and the
1220 # other above conditions haven't been met, count it as a
1221 # printable character
1222 elif each_character != "\r":
1223 rel_pos += glyph_columns(each_character)
1224 if each_character in (" ", "\n"):
1225 last_abs_whitespace = abs_pos
1226 last_rel_whitespace = rel_pos
1228 # increase the absolute position for every character
1231 # return the newly-wrapped text
1235 def weighted_choice(data):
1236 """Takes a dict weighted by value and returns a random key."""
1238 # this will hold our expanded list of keys from the data
1241 # create the expanded list of keys
1242 for key in data.keys():
1243 for _count in range(data[key]):
1244 expanded.append(key)
1246 # return one at random
1247 # Allow the random.randrange() call in bandit since it's not used for
1248 # security/cryptographic purposes
1249 return random.choice(expanded) # nosec
1253 """Returns a random character name."""
1255 # the vowels and consonants needed to create romaji syllables
1284 # this dict will hold our weighted list of syllables
1287 # generate the list with an even weighting
1288 for consonant in consonants:
1289 for vowel in vowels:
1290 syllables[consonant + vowel] = 1
1292 # we'll build the name into this string
1295 # create a name of random length from the syllables
1296 # Allow the random.randrange() call in bandit since it's not used for
1297 # security/cryptographic purposes
1298 for _syllable in range(random.randrange(2, 6)): # nosec
1299 name += weighted_choice(syllables)
1301 # strip any leading quotemark, capitalize and return the name
1302 return name.strip("'").capitalize()
1305 def replace_macros(user, text, is_input=False):
1306 """Replaces macros in text output."""
1308 # third person pronouns
1310 "female": {"obj": "her", "pos": "hers", "sub": "she"},
1311 "male": {"obj": "him", "pos": "his", "sub": "he"},
1312 "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1315 # a dict of replacement macros
1318 "bld": chr(27) + "[1m",
1319 "nrm": chr(27) + "[0m",
1320 "blk": chr(27) + "[30m",
1321 "blu": chr(27) + "[34m",
1322 "cyn": chr(27) + "[36m",
1323 "grn": chr(27) + "[32m",
1324 "mgt": chr(27) + "[35m",
1325 "red": chr(27) + "[31m",
1326 "yel": chr(27) + "[33m",
1329 # add dynamic macros where possible
1331 account_name = user.account.get("name")
1333 macros["account"] = account_name
1335 avatar_gender = user.avatar.get("gender")
1337 macros["tpop"] = pronouns[avatar_gender]["obj"]
1338 macros["tppp"] = pronouns[avatar_gender]["pos"]
1339 macros["tpsp"] = pronouns[avatar_gender]["sub"]
1344 # find and replace per the macros dict
1345 macro_start = text.find("$(")
1346 if macro_start == -1:
1348 macro_end = text.find(")", macro_start) + 1
1349 macro = text[macro_start + 2:macro_end - 1]
1350 if macro in macros.keys():
1351 replacement = macros[macro]
1353 # this is how we handle local file inclusion (dangerous!)
1354 elif macro.startswith("inc:"):
1355 incfile = mudpy.data.find_file(macro[4:], universe=universe)
1356 if os.path.exists(incfile):
1357 incfd = codecs.open(incfile, "r", "utf-8")
1360 if line.endswith("\n") and not line.endswith("\r\n"):
1361 line = line.replace("\n", "\r\n")
1363 # lose the trailing eol
1364 replacement = replacement[:-2]
1367 log("Couldn't read included " + incfile + " file.", 7)
1369 # if we get here, log and replace it with null
1373 log("Unexpected replacement macro " +
1374 macro + " encountered.", 6)
1376 # and now we act on the replacement
1377 text = text.replace("$(" + macro + ")", replacement)
1379 # replace the look-like-a-macro sequence
1380 text = text.replace("$_(", "$(")
1385 def escape_macros(value):
1386 """Escapes replacement macros in text."""
1387 if type(value) is str:
1388 return value.replace("$(", "$_(")
1393 def first_word(text, separator=" "):
1394 """Returns a tuple of the first word and the rest."""
1396 if text.find(separator) > 0:
1397 return text.split(separator, 1)
1405 """The things which should happen on each pulse, aside from reloads."""
1407 # open the listening socket if it hasn't been already
1408 if not hasattr(universe, "listening_socket"):
1409 universe.initialize_server_socket()
1411 # assign a user if a new connection is waiting
1412 user = check_for_connection(universe.listening_socket)
1414 universe.userlist.append(user)
1416 # iterate over the connected users
1417 for user in universe.userlist:
1420 # add an element for counters if it doesn't exist
1421 if "counters" not in universe.groups.get("internal", {}):
1422 Element("internal.counters", universe)
1424 # update the log every now and then
1425 if not universe.groups["internal"]["counters"].get("mark"):
1426 log(str(len(universe.userlist)) + " connection(s)")
1427 universe.groups["internal"]["counters"].set(
1428 "mark", universe.contents["mudpy.timing"].get("status")
1431 universe.groups["internal"]["counters"].set(
1432 "mark", universe.groups["internal"]["counters"].get(
1437 # periodically save everything
1438 if not universe.groups["internal"]["counters"].get("save"):
1440 universe.groups["internal"]["counters"].set(
1441 "save", universe.contents["mudpy.timing"].get("save")
1444 universe.groups["internal"]["counters"].set(
1445 "save", universe.groups["internal"]["counters"].get(
1450 # pause for a configurable amount of time (decimal seconds)
1451 time.sleep(universe.contents["mudpy.timing"].get("increment"))
1453 # increase the elapsed increment counter
1454 universe.groups["internal"]["counters"].set(
1455 "elapsed", universe.groups["internal"]["counters"].get(
1462 """Reload all relevant objects."""
1464 old_userlist = universe.userlist[:]
1465 old_loglines = universe.loglines[:]
1466 for element in list(universe.contents.values()):
1469 new_loglines = universe.loglines[:]
1470 universe.loglines = old_loglines + new_loglines
1471 for user in old_userlist:
1475 def check_for_connection(listening_socket):
1476 """Check for a waiting connection and return a new user object."""
1478 # try to accept a new connection
1480 connection, address = listening_socket.accept()
1481 except BlockingIOError:
1484 # note that we got one
1485 log("New connection from %s." % address[0], 2)
1487 # disable blocking so we can proceed whether or not we can send/receive
1488 connection.setblocking(0)
1490 # create a new user object
1492 log("Instantiated %s for %s." % (user, address[0]), 0)
1494 # associate this connection with it
1495 user.connection = connection
1497 # set the user's ipa from the connection's ipa
1498 user.address = address[0]
1500 # let the client know we WILL EOR (RFC 885)
1501 mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1502 user.negotiation_pause = 2
1504 # return the new user object
1508 def find_command(command_name):
1509 """Try to find a command by name or abbreviation."""
1511 # lowercase the command
1512 command_name = command_name.lower()
1515 if command_name in universe.groups["command"]:
1516 # the command matches a command word for which we have data
1517 command = universe.groups["command"][command_name]
1519 for candidate in sorted(universe.groups["command"]):
1520 if candidate.startswith(command_name) and not universe.groups[
1521 "command"][candidate].is_restricted():
1522 # the command matches the start of a command word and is not
1523 # restricted to administrators
1524 command = universe.groups["command"][candidate]
1529 def get_menu(state, error=None, choices=None):
1530 """Show the correct menu text to a user."""
1532 # make sure we don't reuse a mutable sequence by default
1536 # get the description or error text
1537 message = get_menu_description(state, error)
1539 # get menu choices for the current state
1540 message += get_formatted_menu_choices(state, choices)
1542 # try to get a prompt, if it was defined
1543 message += get_menu_prompt(state)
1545 # throw in the default choice, if it exists
1546 message += get_formatted_default_menu_choice(state)
1548 # display a message indicating if echo is off
1549 message += get_echo_message(state)
1551 # return the assembly of various strings defined above
1555 def menu_echo_on(state):
1556 """True if echo is on, false if it is off."""
1557 return universe.groups["menu"][state].get("echo", True)
1560 def get_echo_message(state):
1561 """Return a message indicating that echo is off."""
1562 if menu_echo_on(state):
1565 return "(won't echo) "
1568 def get_default_menu_choice(state):
1569 """Return the default choice for a menu."""
1570 return universe.groups["menu"][state].get("default")
1573 def get_formatted_default_menu_choice(state):
1574 """Default menu choice foratted for inclusion in a prompt string."""
1575 default_choice = get_default_menu_choice(state)
1577 return "[$(red)" + default_choice + "$(nrm)] "
1582 def get_menu_description(state, error):
1583 """Get the description or error text."""
1585 # an error condition was raised by the handler
1588 # try to get an error message matching the condition
1590 description = universe.groups[
1591 "menu"][state].get("error_" + error)
1593 description = "That is not a valid choice..."
1594 description = "$(red)" + description + "$(nrm)"
1596 # there was no error condition
1599 # try to get a menu description for the current state
1600 description = universe.groups["menu"][state].get("description")
1602 # return the description or error message
1604 description += "$(eol)$(eol)"
1608 def get_menu_prompt(state):
1609 """Try to get a prompt, if it was defined."""
1610 prompt = universe.groups["menu"][state].get("prompt")
1616 def get_menu_choices(user):
1617 """Return a dict of choice:meaning."""
1618 state = universe.groups["menu"][user.state]
1619 create_choices = state.get("create")
1621 choices = call_hook_function(create_choices, (user,))
1627 for facet in state.facets():
1628 if facet.startswith("demand_") and not call_hook_function(
1629 universe.groups["menu"][user.state].get(facet), (user,)):
1630 ignores.append(facet.split("_", 2)[1])
1631 elif facet.startswith("create_"):
1632 creates[facet] = facet.split("_", 2)[1]
1633 elif facet.startswith("choice_"):
1634 options[facet] = facet.split("_", 2)[1]
1635 for facet in creates.keys():
1636 if not creates[facet] in ignores:
1637 choices[creates[facet]] = call_hook_function(
1638 state.get(facet), (user,))
1639 for facet in options.keys():
1640 if not options[facet] in ignores:
1641 choices[options[facet]] = state.get(facet)
1645 def get_formatted_menu_choices(state, choices):
1646 """Returns a formatted string of menu choices."""
1648 choice_keys = list(choices.keys())
1650 for choice in choice_keys:
1651 choice_output += " [$(red)" + choice + "$(nrm)] " + choices[
1655 choice_output += "$(eol)"
1656 return choice_output
1659 def get_menu_branches(state):
1660 """Return a dict of choice:branch."""
1662 for facet in universe.groups["menu"][state].facets():
1663 if facet.startswith("branch_"):
1665 facet.split("_", 2)[1]
1666 ] = universe.groups["menu"][state].get(facet)
1670 def get_default_branch(state):
1671 """Return the default branch."""
1672 return universe.groups["menu"][state].get("branch")
1675 def get_choice_branch(user):
1676 """Returns the new state matching the given choice."""
1677 branches = get_menu_branches(user.state)
1678 if user.choice in branches.keys():
1679 return branches[user.choice]
1680 elif user.choice in user.menu_choices.keys():
1681 return get_default_branch(user.state)
1686 def get_menu_actions(state):
1687 """Return a dict of choice:branch."""
1689 for facet in universe.groups["menu"][state].facets():
1690 if facet.startswith("action_"):
1692 facet.split("_", 2)[1]
1693 ] = universe.groups["menu"][state].get(facet)
1697 def get_default_action(state):
1698 """Return the default action."""
1699 return universe.groups["menu"][state].get("action")
1702 def get_choice_action(user):
1703 """Run any indicated script for the given choice."""
1704 actions = get_menu_actions(user.state)
1705 if user.choice in actions.keys():
1706 return actions[user.choice]
1707 elif user.choice in user.menu_choices.keys():
1708 return get_default_action(user.state)
1713 def call_hook_function(fname, arglist):
1714 """Safely execute named function with supplied arguments, return result."""
1716 # all functions relative to mudpy package
1719 for component in fname.split("."):
1721 function = getattr(function, component)
1722 except AttributeError:
1723 log('Could not find mudpy.%s() for arguments "%s"'
1724 % (fname, arglist), 7)
1729 return function(*arglist)
1731 log('Calling mudpy.%s(%s) raised an exception...\n%s'
1732 % (fname, (*arglist,), traceback.format_exc()), 7)
1735 def handle_user_input(user):
1736 """The main handler, branches to a state-specific handler."""
1738 # if the user's client echo is off, send a blank line for aesthetics
1739 if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1741 user.send("", add_prompt=False, prepend_padding=False)
1743 # check to make sure the state is expected, then call that handler
1745 globals()["handler_" + user.state](user)
1747 generic_menu_handler(user)
1749 # since we got input, flag that the menu/prompt needs to be redisplayed
1750 user.menu_seen = False
1752 # update the last_input timestamp while we're at it
1753 user.last_input = universe.get_time()
1756 def generic_menu_handler(user):
1757 """A generic menu choice handler."""
1759 # get a lower-case representation of the next line of input
1760 if user.input_queue:
1761 user.choice = user.input_queue.pop(0)
1763 user.choice = user.choice.lower()
1767 user.choice = get_default_menu_choice(user.state)
1768 if user.choice in user.menu_choices:
1769 action = get_choice_action(user)
1771 call_hook_function(action, (user,))
1772 new_state = get_choice_branch(user)
1774 user.state = new_state
1776 user.error = "default"
1779 def handler_entering_account_name(user):
1780 """Handle the login account name."""
1782 # get the next waiting line of input
1783 input_data = user.input_queue.pop(0)
1785 # did the user enter anything?
1788 # keep only the first word and convert to lower-case
1789 name = input_data.lower()
1791 # fail if there are non-alphanumeric characters
1792 if name != "".join(filter(
1793 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1795 user.error = "bad_name"
1797 # if that account exists, time to request a password
1798 elif name in universe.groups.get("account", {}):
1799 user.account = universe.groups["account"][name]
1800 user.state = "checking_password"
1802 # otherwise, this could be a brand new user
1804 user.account = Element("account.%s" % name, universe)
1805 user.account.set("name", name)
1806 log("New user: " + name, 2)
1807 user.state = "checking_new_account_name"
1809 # if the user entered nothing for a name, then buhbye
1811 user.state = "disconnecting"
1814 def handler_checking_password(user):
1815 """Handle the login account password."""
1817 # get the next waiting line of input
1818 input_data = user.input_queue.pop(0)
1820 if "mudpy.limit" in universe.contents:
1821 max_password_tries = universe.contents["mudpy.limit"].get(
1822 "password_tries", 3)
1824 max_password_tries = 3
1826 # does the hashed input equal the stored hash?
1827 if mudpy.password.verify(input_data, user.account.get("passhash")):
1829 # if so, set the username and load from cold storage
1830 if not user.replace_old_connections():
1832 user.state = "main_utility"
1834 # if at first your hashes don't match, try, try again
1835 elif user.password_tries < max_password_tries - 1:
1836 user.password_tries += 1
1837 user.error = "incorrect"
1839 # we've exceeded the maximum number of password failures, so disconnect
1842 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1844 user.state = "disconnecting"
1847 def handler_entering_new_password(user):
1848 """Handle a new password entry."""
1850 # get the next waiting line of input
1851 input_data = user.input_queue.pop(0)
1853 if "mudpy.limit" in universe.contents:
1854 max_password_tries = universe.contents["mudpy.limit"].get(
1855 "password_tries", 3)
1857 max_password_tries = 3
1859 # make sure the password is strong--at least one upper, one lower and
1860 # one digit, seven or more characters in length
1861 if len(input_data) > 6 and len(
1862 list(filter(lambda x: x >= "0" and x <= "9", input_data))
1864 list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1866 list(filter(lambda x: x >= "a" and x <= "z", input_data))
1869 # hash and store it, then move on to verification
1870 user.account.set("passhash", mudpy.password.create(input_data))
1871 user.state = "verifying_new_password"
1873 # the password was weak, try again if you haven't tried too many times
1874 elif user.password_tries < max_password_tries - 1:
1875 user.password_tries += 1
1878 # too many tries, so adios
1881 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1883 user.account.destroy()
1884 user.state = "disconnecting"
1887 def handler_verifying_new_password(user):
1888 """Handle the re-entered new password for verification."""
1890 # get the next waiting line of input
1891 input_data = user.input_queue.pop(0)
1893 if "mudpy.limit" in universe.contents:
1894 max_password_tries = universe.contents["mudpy.limit"].get(
1895 "password_tries", 3)
1897 max_password_tries = 3
1899 # hash the input and match it to storage
1900 if mudpy.password.verify(input_data, user.account.get("passhash")):
1903 # the hashes matched, so go active
1904 if not user.replace_old_connections():
1905 user.state = "main_utility"
1907 # go back to entering the new password as long as you haven't tried
1909 elif user.password_tries < max_password_tries - 1:
1910 user.password_tries += 1
1911 user.error = "differs"
1912 user.state = "entering_new_password"
1914 # otherwise, sayonara
1917 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1919 user.account.destroy()
1920 user.state = "disconnecting"
1923 def handler_active(user):
1924 """Handle input for active users."""
1926 # get the next waiting line of input
1927 input_data = user.input_queue.pop(0)
1932 # split out the command and parameters
1934 mode = actor.get("mode")
1935 if mode and input_data.startswith("!"):
1936 command_name, parameters = first_word(input_data[1:])
1937 elif mode == "chat":
1938 command_name = "say"
1939 parameters = input_data
1941 command_name, parameters = first_word(input_data)
1943 # expand to an actual command
1944 command = find_command(command_name)
1946 # if it's allowed, do it
1948 if actor.can_run(command):
1949 action_fname = command.get("action", command.key)
1951 result = call_hook_function(action_fname, (actor, parameters))
1953 # if the command was not run, give an error
1955 mudpy.command.error(actor, input_data)
1957 # if no input, just idle back with a prompt
1959 user.send("", just_prompt=True)
1962 def daemonize(universe):
1963 """Fork and disassociate from everything."""
1965 # only if this is what we're configured to do
1966 if "mudpy.process" in universe.contents and universe.contents[
1967 "mudpy.process"].get("daemon"):
1969 # log before we start forking around, so the terminal gets the message
1970 log("Disassociating from the controlling terminal.")
1972 # fork off and die, so we free up the controlling terminal
1976 # switch to a new process group
1979 # fork some more, this time to free us from the old process group
1983 # reset the working directory so we don't needlessly tie up mounts
1986 # clear the file creation mask so we can bend it to our will later
1989 # redirect stdin/stdout/stderr and close off their former descriptors
1990 for stdpipe in range(3):
1992 sys.stdin = codecs.open("/dev/null", "r", "utf-8")
1993 sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
1994 sys.stdout = codecs.open("/dev/null", "w", "utf-8")
1995 sys.stderr = codecs.open("/dev/null", "w", "utf-8")
1996 sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
1997 sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
2000 def create_pidfile(universe):
2001 """Write a file containing the current process ID."""
2002 pid = str(os.getpid())
2003 log("Process ID: " + pid)
2004 if "mudpy.process" in universe.contents:
2005 file_name = universe.contents["mudpy.process"].get("pidfile", "")
2009 if not os.path.isabs(file_name):
2010 file_name = os.path.join(universe.startdir, file_name)
2011 os.makedirs(os.path.dirname(file_name), exist_ok=True)
2012 file_descriptor = codecs.open(file_name, "w", "utf-8")
2013 file_descriptor.write(pid + "\n")
2014 file_descriptor.flush()
2015 file_descriptor.close()
2018 def remove_pidfile(universe):
2019 """Remove the file containing the current process ID."""
2020 if "mudpy.process" in universe.contents:
2021 file_name = universe.contents["mudpy.process"].get("pidfile", "")
2025 if not os.path.isabs(file_name):
2026 file_name = os.path.join(universe.startdir, file_name)
2027 if os.access(file_name, os.W_OK):
2028 os.remove(file_name)
2031 def excepthook(excepttype, value, tracebackdata):
2032 """Handle uncaught exceptions."""
2034 # assemble the list of errors into a single string
2036 traceback.format_exception(excepttype, value, tracebackdata)
2039 # try to log it, if possible
2042 except Exception as e:
2043 # try to write it to stderr, if possible
2044 sys.stderr.write(message + "\nException while logging...\n%s" % e)
2047 def sighook(what, where):
2048 """Handle external signals."""
2051 message = "Caught signal: "
2053 # for a hangup signal
2054 if what == signal.SIGHUP:
2055 message += "hangup (reloading)"
2056 universe.reload_flag = True
2058 # for a terminate signal
2059 elif what == signal.SIGTERM:
2060 message += "terminate (halting)"
2061 universe.terminate_flag = True
2063 # catchall for unexpected signals
2065 message += str(what) + " (unhandled)"
2071 def override_excepthook():
2072 """Redefine sys.excepthook with our own."""
2073 sys.excepthook = excepthook
2076 def assign_sighook():
2077 """Assign a customized handler for some signals."""
2078 signal.signal(signal.SIGHUP, sighook)
2079 signal.signal(signal.SIGTERM, sighook)
2083 """This contains functions to be performed when starting the engine."""
2085 # see if a configuration file was specified
2086 if len(sys.argv) > 1:
2087 conffile = sys.argv[1]
2093 universe = Universe(conffile, True)
2095 # report any loglines which accumulated during setup
2096 for logline in universe.setup_loglines:
2098 universe.setup_loglines = []
2100 # fork and disassociate
2103 # override the default exception handler so we get logging first thing
2104 override_excepthook()
2106 # set up custom signal handlers
2110 create_pidfile(universe)
2112 # load and store diagnostic info
2113 universe.versions = mudpy.version.Versions("mudpy")
2115 # log startup diagnostic messages
2116 log("On %s at %s" % (universe.versions.python_version, sys.executable), 1)
2117 log("Import path: %s" % ", ".join(sys.path), 1)
2118 log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
2119 log("Other python packages: %s" % universe.versions.environment_text, 1)
2120 log("Running version: %s" % universe.versions.version, 1)
2121 log("Initial directory: %s" % universe.startdir, 1)
2122 log("Command line: %s" % " ".join(sys.argv), 1)
2123 if universe.debug_mode():
2124 log("WARNING: Unsafe debugging mode is enabled!", 6)
2126 # pass the initialized universe back
2131 """These are functions performed when shutting down the engine."""
2133 # the loop has terminated, so save persistent data
2136 # log a final message
2137 log("Shutting down now.")
2139 # get rid of the pidfile
2140 remove_pidfile(universe)